diff --git a/.github/actions/prepare-build/action.yml b/.github/actions/prepare-build/action.yml index d64d579520..13871a25b8 100644 --- a/.github/actions/prepare-build/action.yml +++ b/.github/actions/prepare-build/action.yml @@ -10,21 +10,19 @@ inputs: 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.2.0 description: "GHC version to install" +outputs: + cabal-store: + description: "Cabal store path" + value: ${{ steps.setup.outputs.cabal-store }} runs: using: "composite" steps: - name: Setup Haskell + id: setup uses: simplex-chat/setup-haskell-action@v2 with: ghc-version: ${{ inputs.ghc_ver }} @@ -37,11 +35,3 @@ runs: 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/workflows/android-emu.yml b/.github/workflows/android-emu.yml new file mode 100644 index 0000000000..1d195d653b --- /dev/null +++ b/.github/workflows/android-emu.yml @@ -0,0 +1,30 @@ +name: android-emu + +# A placeholder, so that the Android app test workflow can be started by hand on a branch that has its full +# version: GitHub dispatches only workflows whose file is on the default branch. On master it does nothing. +on: + workflow_dispatch: + inputs: + claude: + description: Also run the Claude job + type: boolean + default: false + backend: + description: Also start the test backend + type: boolean + default: false + release: + description: Also test the latest release's onboarding + type: boolean + default: false + +permissions: + contents: read + +jobs: + placeholder: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Nothing to run here + run: echo "This branch has only the placeholder of android-emu; start it on a branch with the full workflow." diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d92e2507c9..68737fdb3f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,9 +27,14 @@ on: - "scripts/desktop/**" - ".github/**" -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }} +env: + opt: ${{ github.event_name == 'pull_request' && 'O0' || 'O2' }} + ghc_options: ${{ github.event_name == 'pull_request' && '--ghc-options=-O0' || '' }} + release: ${{ startsWith(github.ref, 'refs/tags/v') }} + branch: ${{ github.base_ref || github.ref_name }} + cache_o0: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') }} + release_branch: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') && github.ref_name || '' }} + ZSTD_CLEVEL: 9 # This workflow uses custom actions (prepare-build and prepare-release) defined in: # @@ -70,14 +75,44 @@ jobs: # ============================= # Create release, but only if it's triggered by tag push. -# On pull requests/commits push, this job will always complete. +# On pushes to master or stable: claim the simplex-chat-libs release core- for the branch +# and create its draft, create the draft of a missing add-on release, and find the package versions to publish. +# On pull requests and other pushes, this job will always complete. + +# Secrets: +# ------- +# NODEJS_REPO_TOKEN +# Only select repositories: simplex-chat-libs +# Permissions: +# * Contents (Read and Write) maybe-release: runs-on: ubuntu-latest + outputs: + libs_tag: ${{ steps.libs.outputs.tag }} + core_changed: ${{ steps.core.outputs.core }} + addon_tag: ${{ steps.addon.outputs.tag }} + addon_draft: ${{ steps.addon.outputs.draft }} + libs_latest: ${{ steps.latest_libs.outputs.tag }} + publish_types: ${{ steps.libs.outputs.tag && steps.publish.outputs.types_version || '' }} + publish_nodejs: ${{ steps.publish.outputs.nodejs_version }} + publish_python: ${{ steps.publish.outputs.python_version }} + publish: ${{ steps.publish.outputs.nodejs_version || steps.publish.outputs.python_version }} steps: - name: Clone project - if: startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' || github.event_name == 'pull_request' uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Check chat core change + id: core + if: github.event_name == 'pull_request' + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: | + git fetch -q --depth=1 origin "$BASE" + python3 scripts/ci/packages_release.py changes "$BASE" | grep '^core=' >> "$GITHUB_OUTPUT" - name: Build changelog id: build_changelog @@ -103,6 +138,69 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Library release + id: libs + if: env.release_branch + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: | + version=$(sed -n 's/^version: *//p' simplex-chat.cabal) + tag=core-$version + claim_output=$(python3 scripts/ci/packages_release.py claim-core "$version" "$GITHUB_SHA" "$release_branch") + eval "$claim_output" + if [ "$claim" = skip ]; then + echo "::notice title=Library release::core $version is recorded for another commit" + exit 0 + fi + status_output=$(python3 scripts/ci/packages_release.py release-status "$tag") + eval "$status_output" + if [ "$status" = absent ]; then + gh release create "$tag" --repo "$LIBS_REPOSITORY" --draft --title "$tag" --notes "" ${commit:+--target "$commit"} + fi + if [ "$status" != published ]; then + echo "tag=$tag" >> "$GITHUB_OUTPUT" + fi + + - name: Add-on release + id: addon + if: env.release_branch + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: | + addon_output=$(python3 scripts/ci/packages_release.py addon-release) + eval "$addon_output" + if [ "$status" = absent ]; then + tag=nodejs-addon-${GITHUB_SHA::7} + gh release create "$tag" --repo "$LIBS_REPOSITORY" --draft --title "$tag" --notes "$notes" + fi + if [ "$status" != published ]; then + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "draft=true" >> "$GITHUB_OUTPUT" + fi + + - name: Latest library + id: latest_libs + if: steps.addon.outputs.draft + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: python3 scripts/ci/packages_release.py latest-libs --branch "$release_branch" >> "$GITHUB_OUTPUT" + + - name: Versions to publish + id: publish + if: env.release_branch + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT" + # ========================= # Linux Build # ========================= @@ -111,6 +209,15 @@ jobs: name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.runner }} + permissions: + contents: write + packages: write + env: + image_cache: ghcr.io/simplex-chat/simplex-chat-build-cache:${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }} + source_hashes: dist-newstyle/sources.sha256 + concurrency: &linux-concurrency + group: ${{ github.workflow }}-${{ github.ref }}-linux-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.ghc }}-${{ needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -129,6 +236,7 @@ jobs: should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' + libs: true - os: 24.04 os_underscore: 24_04 arch: x86_64 @@ -136,21 +244,7 @@ jobs: should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} hash: 'sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237' - - os: 22.04 - os_underscore: 22_04 - arch: aarch64 - runner: "ubuntu-22.04-arm" - should_run: true - ghc: ${{ needs.variables.outputs.GHC_VER }} - hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' - - os: 24.04 - os_underscore: 24_04 - arch: aarch64 - runner: "ubuntu-24.04-arm" - should_run: true - ghc: ${{ needs.variables.outputs.GHC_VER }} - hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' - steps: + steps: &linux-steps - name: Checkout Code if: matrix.should_run == true uses: actions/checkout@v3 @@ -175,19 +269,22 @@ jobs: shell: bash run: ./scripts/ci/linux_util_free_space.sh - - name: Restore cached build - if: matrix.should_run == true - uses: actions/cache@v4 - with: - path: | - ~/.cabal/store - dist-newstyle - key: ubuntu-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} - - name: Set up Docker Buildx if: matrix.should_run == true uses: simplex-chat/docker-setup-buildx-action@v3 + - name: Log in to GitHub Container Registry + if: matrix.should_run == true && env.release != 'true' + shell: bash + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: echo "$GHCR_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + - name: Compute image cache week + id: week + if: matrix.should_run == true && env.release != 'true' + run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" + - name: Build and cache Docker image if: matrix.should_run == true uses: simplex-chat/docker-build-push-action@v6 @@ -202,6 +299,8 @@ jobs: GHC=${{ matrix.ghc }} USER_UID=${{ steps.ids.outputs.uid }} USER_GID=${{ steps.ids.outputs.gid }} + cache-from: ${{ env.release != 'true' && format('type=registry,ref={0}-{1}', env.image_cache, steps.week.outputs.week) || '' }} + cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && format('type=registry,ref={0}-{1},mode=max,ignore-error=true', env.image_cache, steps.week.outputs.week) || '' }} # Docker needs these flags for AppImage build: # --device /dev/fuse @@ -211,16 +310,94 @@ jobs: if: matrix.should_run == true shell: bash run: | + mkdir -p ~/.cabal docker run -t -d \ --device /dev/fuse \ --cap-add SYS_ADMIN \ --security-opt apparmor:unconfined \ --name builder \ - -v ~/.cabal:/root/.cabal \ + -e CABAL_DIR=/home/builder/.cabal \ + -v ~/.cabal:/home/builder/.cabal \ -v /home/runner/work/_temp:/home/runner/work/_temp \ -v ${{ github.workspace }}:/project \ build/${{ matrix.os }}:latest + - name: Compute cache key prefixes + id: keys + if: matrix.should_run == true && env.release != 'true' + run: | + toolchain=$(docker exec builder dpkg-query -W -f '${db:Status-Status} ${binary:Package} ${Version}\n' 'binutils*' 'cpp*' 'gcc*' '*-dev' | grep '^installed ' | sha256sum | cut -c1-16) + target=ubuntu-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-$toolchain + echo "deps=deps-$target" >> "$GITHUB_OUTPUT" + echo "dist=dist-$target" >> "$GITHUB_OUTPUT" + echo "dist_lib=dist-lib-$target" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + if: matrix.should_run == true && env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + + - name: Restore source repositories from cache + id: restore-src + if: matrix.should_run == true && env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + restore-keys: src-${{ runner.os }}- + + - name: Warn if source repositories not in cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::src-${{ runner.os }}" + + - name: Restore build directory from cache + id: restore-build + if: matrix.should_run == true && env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-${{ env.opt }}- + + - name: Warn if build directory not in cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-build.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" + + - name: Restore library build directory from cache + id: restore-build-lib + if: env.release != 'true' && matrix.libs && needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64' + uses: actions/cache/restore@v4 + with: + path: | + dist-lib/* + !dist-lib/src + key: ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist_lib }}- + + - name: Warn if library build directory not in cache + if: steps.restore-build-lib.outcome == 'success' && steps.restore-build-lib.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}" + + - name: Set old timestamps on unchanged sources (GHC 8.10.7) + if: matrix.ghc == '8.10.7' && steps.restore-build.outputs.cache-matched-key != '' && hashFiles(env.source_hashes) != '' + run: scripts/ci/source-timestamps.sh restore ${{ env.source_hashes }} + - name: Prepare cabal.project.local if: matrix.should_run == true shell: bash @@ -229,22 +406,89 @@ jobs: 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 + id: build-cli if: matrix.should_run == true shell: docker exec -t builder sh -eu {0} run: | - cabal clean + scripts/ci/prune-old-builds.sh dist-newstyle cabal update - cabal build -j --enable-tests + cabal build -j --enable-tests ${{ env.ghc_options }} 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/ + cp "$bin" /out/ done strip /out/simplex-chat + # Also for pull requests that change the chat core, for the package tests. + - name: Build library + id: build-lib + if: matrix.libs && (needs.maybe-release.outputs.libs_tag || (needs.maybe-release.outputs.core_changed && matrix.arch == 'x86_64')) + shell: docker exec -t builder sh -eu {0} + run: | + scripts/ci/prune-old-builds.sh dist-lib + mkdir -p dist-lib + rm -rf dist-lib/src + ln -s ../dist-newstyle/src dist-lib/src + DIST_DIR=dist-lib SKIP_VLC=1 scripts/desktop/build-lib-linux.sh + name=simplex-chat-libs-linux-${{ matrix.arch }} + mkdir -p release-libs/$name/libs + find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-${{ matrix.arch }} -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + (cd release-libs/$name && zip -r ../$name.zip libs) + + - name: Record source hashes (GHC 8.10.7) + if: matrix.should_run == true && matrix.ghc == '8.10.7' + shell: docker exec -t builder sh -eu {0} + run: scripts/ci/source-timestamps.sh record ${{ env.source_hashes }} + + - name: Compute dependency cache key + id: hash-deps + if: matrix.should_run == true && env.release != 'true' + shell: bash + run: echo "hash=$(ls ~/.cabal/store/ghc-*/ | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + + - name: Save source repositories to cache + if: matrix.should_run == true && env.release != 'true' && matrix.libs && matrix.arch == 'x86_64' && steps.restore-src.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + + - name: Save build directory to cache + if: matrix.should_run == true && env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + + - name: Save library build directory to cache + if: steps.build-lib.outcome == 'success' && env.release != 'true' && steps.restore-build-lib.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-lib/* + !dist-lib/src + key: ${{ steps.keys.outputs.dist_lib }}-${{ env.branch }}-${{ github.sha }} + + - name: Upload library + if: needs.maybe-release.outputs.libs_tag && matrix.libs + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" + - name: Build CLI deb if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: docker exec -t builder sh -eu {0} @@ -353,12 +597,13 @@ jobs: github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Fix permissions for cache - if: matrix.should_run == true + - name: Mount RAM disk for tests + if: matrix.should_run == true && matrix.arch == 'x86_64' shell: bash run: | - sudo chmod -R 777 dist-newstyle ~/.cabal - sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal + mkdir -p tests/tmp + sudo mount -t tmpfs -o size=512M tmpfs tests/tmp + sudo chown $(id -u):$(id -g) tests/tmp - name: Run tests if: matrix.should_run == true && matrix.arch == 'x86_64' @@ -382,6 +627,136 @@ jobs: exit 1 fi + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && ((needs.maybe-release.outputs.core_changed && steps.build-lib.outcome == 'success') || (matrix.libs && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish))) }} + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Package tests (pull request) + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.core_changed }} + timeout-minutes: 30 + shell: bash + env: + SIMPLEX_LIBS_DIR: ${{ github.workspace }}/release-libs/simplex-chat-libs-linux-${{ matrix.arch }}/libs + run: | + scripts/ci/package-tests.sh build linux-${{ matrix.arch }} + scripts/ci/package-tests.sh test + + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon linux-${{ matrix.arch }} + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + timeout-minutes: 30 + shell: bash + run: | + scripts/ci/package-tests.sh publish linux-${{ matrix.arch }} ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "linux-${{ matrix.arch }}" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-linux-${{ matrix.arch }} + path: ${{ runner.temp }}/package-tests + + - name: Remove -O2 build directory + id: remove-o2 + if: env.cache_o0 == 'true' && !cancelled() && steps.build-cli.outcome == 'success' + shell: docker exec -t builder sh -eu {0} + run: find dist-newstyle -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + + + - name: Restore -O0 build directory from cache + id: restore-build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-O0- + + - name: Set old timestamps on unchanged sources for -O0 build (GHC 8.10.7) + if: ${{ !cancelled() && matrix.ghc == '8.10.7' && steps.restore-build-o0.outputs.cache-matched-key != '' && hashFiles(env.source_hashes) != '' }} + run: scripts/ci/source-timestamps.sh restore ${{ env.source_hashes }} + + - name: Build at -O0 for pull request caches + id: build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + shell: docker exec -t builder sh -eu {0} + run: | + scripts/ci/prune-old-builds.sh dist-newstyle + cabal build -j --enable-tests --ghc-options=-O0 + + - name: Record source hashes for -O0 build (GHC 8.10.7) + if: ${{ !cancelled() && matrix.ghc == '8.10.7' && steps.build-o0.outcome == 'success' }} + shell: docker exec -t builder sh -eu {0} + run: scripts/ci/source-timestamps.sh record ${{ env.source_hashes }} + + - name: Save -O0 build directory to cache + if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + + build-linux-push: + name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" + needs: [maybe-release, variables] + if: github.event_name != 'pull_request' + runs-on: ${{ matrix.runner }} + permissions: + contents: write + packages: write + env: + image_cache: ghcr.io/simplex-chat/simplex-chat-build-cache:${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }} + source_hashes: dist-newstyle/sources.sha256 + cache_o0: false + concurrency: *linux-concurrency + strategy: + fail-fast: false + matrix: + include: + - os: 22.04 + os_underscore: 22_04 + arch: aarch64 + runner: "ubuntu-22.04-arm" + should_run: true + ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' + libs: true + - os: 24.04 + os_underscore: 24_04 + arch: aarch64 + runner: "ubuntu-24.04-arm" + should_run: true + ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' + steps: *linux-steps + # ================================= # Linux PostgreSQL Library Build # ================================= @@ -390,7 +765,7 @@ jobs: name: "ubuntu-22.04-x86_64 (Postgres lib), GHC: ${{ needs.variables.outputs.GHC_VER }}" needs: [maybe-release, variables] runs-on: ubuntu-22.04 - if: startsWith(github.ref, 'refs/tags/v') + if: needs.maybe-release.outputs.libs_tag steps: - name: Checkout Code uses: actions/checkout@v3 @@ -405,14 +780,6 @@ jobs: shell: bash run: ./scripts/ci/linux_util_free_space.sh - - name: Restore cached build - uses: actions/cache@v4 - with: - path: | - ~/.cabal/store - dist-newstyle - key: ubuntu-22.04-x86_64-postgres-ghc${{ needs.variables.outputs.GHC_VER }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }} - - name: Set up Docker Buildx uses: simplex-chat/docker-setup-buildx-action@v3 @@ -433,13 +800,35 @@ jobs: - name: Start container shell: bash run: | + mkdir -p ~/.cabal docker run -t -d \ --name builder \ - -v ~/.cabal:/root/.cabal \ + -e CABAL_DIR=/home/builder/.cabal \ + -v ~/.cabal:/home/builder/.cabal \ -v /home/runner/work/_temp:/home/runner/work/_temp \ -v ${{ github.workspace }}:/project \ build/22.04:latest + - name: Compute cache key prefix + id: keys + run: | + toolchain=$(docker exec builder dpkg-query -W -f '${db:Status-Status} ${binary:Package} ${Version}\n' 'binutils*' 'cpp*' 'gcc*' '*-dev' | grep '^installed ' | sha256sum | cut -c1-16) + echo "deps=deps-ubuntu-22.04-x86_64-postgres-ghc${{ needs.variables.outputs.GHC_VER }}-$toolchain" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + uses: actions/cache/restore@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + - name: Prepare cabal.project.local shell: bash run: | @@ -450,31 +839,38 @@ jobs: - name: Build postgres library shell: docker exec -t builder sh -eu {0} run: | - cabal clean cabal update scripts/desktop/build-lib-linux.sh postgres + - name: Compute dependency cache key + id: hash-deps + shell: bash + run: echo "hash=$(ls ~/.cabal/store/ghc-*/ | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ~/.cabal/store + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + - name: Copy libs from container shell: bash run: | ARCH=x86_64 GHC_VER=${{ needs.variables.outputs.GHC_VER }} BUILD_DIR=$(echo dist-newstyle/build/${ARCH}-linux/ghc-${GHC_VER}/simplex-chat-*) - mkdir -p postgres-libs - cp ${BUILD_DIR}/build/libsimplex.so postgres-libs/ - cp ${BUILD_DIR}/build/deps/* postgres-libs/ + mkdir -p postgres-libs/libs + cp ${BUILD_DIR}/build/libsimplex.so postgres-libs/libs/ + cp ${BUILD_DIR}/build/deps/* postgres-libs/libs/ + (cd postgres-libs && zip -r simplex-chat-libs-linux-x86_64-postgres.zip libs) - - name: Upload postgres libs artifact - uses: actions/upload-artifact@v4 - with: - name: simplex-libs-linux-postgres-x86_64 - path: postgres-libs/ - - - name: Fix permissions for cache + - name: Upload postgres library shell: bash - run: | - sudo chmod -R 777 dist-newstyle ~/.cabal - sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" postgres-libs/*.zip --clobber --repo "${{ github.repository }}-libs" # ========================= # MacOS Build @@ -484,16 +880,21 @@ jobs: name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.os }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-macos-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: include: - os: macos-latest + arch: aarch64 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-15-intel + arch: x86_64 ghc: ${{ needs.variables.outputs.GHC_VER }} cli_asset_name: simplex-chat-macos-x86-64 desktop_asset_name: simplex-desktop-macos-x86_64.dmg @@ -504,14 +905,49 @@ jobs: with: submodules: recursive + - name: Point Command Line Tools SDK to Xcode SDK + shell: bash + run: sudo ln -sfn "$(xcrun --sdk macosx --show-sdk-path)" /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk + + - name: Compute toolchain cache key + id: toolchain-key + if: env.release != 'true' + shell: bash + run: echo "key=ghcup-${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-cabal3.10.2.0-$({ sw_vers -productVersion; clang --version; } | shasum -a 256 | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Restore toolchain from cache + id: restore-toolchain + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: | + ~/.ghcup/ghc/${{ matrix.ghc }} + ~/.ghcup/bin/*-${{ matrix.ghc }} + ~/.ghcup/bin/cabal-3.10.2.0 + key: ${{ steps.toolchain-key.outputs.key }} + + - name: Warn if toolchain not in cache + if: env.release != 'true' && steps.restore-toolchain.outputs.cache-hit != 'true' + run: echo "::warning title=Cache miss::${{ steps.toolchain-key.outputs.key }}" + - name: Prepare build + id: prepare 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: Save toolchain to cache + if: env.release != 'true' && steps.restore-toolchain.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.ghcup/ghc/${{ matrix.ghc }} + ~/.ghcup/bin/*-${{ matrix.ghc }} + ~/.ghcup/bin/cabal-3.10.2.0 + key: ${{ steps.toolchain-key.outputs.key }} + - name: Install OpenSSL run: brew install openssl@3.0 @@ -520,7 +956,7 @@ jobs: 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-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 "" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local @@ -528,15 +964,100 @@ jobs: echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local + - name: Compute cache key prefixes + id: keys + if: env.release != 'true' + shell: bash + run: | + toolchain=$({ sw_vers -productVersion; clang --version; } | shasum -a 256 | cut -c1-16) + target=${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }}-$toolchain + echo "deps=deps-$target" >> "$GITHUB_OUTPUT" + echo "dist=dist-$target" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + + - name: Restore source repositories from cache + id: restore-src + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + restore-keys: src-${{ runner.os }}- + + - name: Warn if source repositories not in cache + if: env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::src-${{ runner.os }}" + + - name: Restore build directory from cache + id: restore-build + if: env.release != 'true' && !needs.maybe-release.outputs.libs_tag + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-${{ env.opt }}- + + - name: Warn if build directory not in cache + if: steps.restore-build.outcome == 'success' && steps.restore-build.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" + - name: Build CLI id: mac_cli_build shell: bash run: | - cabal build -j --enable-tests - path=$(cabal list-bin simplex-chat) + scripts/ci/prune-old-builds.sh dist-newstyle + cabal build -j --enable-tests ${{ env.ghc_options }} + 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: Compute dependency cache key + id: hash-deps + if: env.release != 'true' + shell: bash + run: echo "hash=$(ls '${{ steps.prepare.outputs.cabal-store }}'/ghc-*/ | shasum -a 256 | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + + - name: Save source repositories to cache + if: env.release != 'true' && matrix.arch == 'x86_64' && steps.restore-src.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + + - name: Save build directory to cache + if: env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') uses: ./.github/actions/prepare-release @@ -573,6 +1094,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Run tests + if: matrix.arch == 'aarch64' timeout-minutes: 120 shell: bash run: | @@ -580,7 +1102,7 @@ jobs: 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 + if cabal test --test-show-details=direct ${{ env.ghc_options }}; then break else echo "Attempt $i failed, retrying..." @@ -593,6 +1115,107 @@ jobs: exit 1 fi + - name: Build library + id: mac_lib_build + if: ${{ !cancelled() && needs.maybe-release.outputs.libs_tag && steps.mac_cli_build.outcome == 'success' }} + 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: | + SKIP_VLC=1 scripts/desktop/build-lib-mac.sh + name=simplex-chat-libs-macos-${{ matrix.arch }} + mkdir -p release-libs/$name/libs + find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-${{ matrix.arch }} -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + scripts/ci/sign-libs-mac.sh release-libs/$name/libs + (cd release-libs/$name && zip -r ../$name.zip libs) + + - name: Upload library + if: ${{ !cancelled() && steps.mac_lib_build.outcome == 'success' }} + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" + + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon macos-${{ matrix.arch }} + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + timeout-minutes: 30 + shell: bash + run: | + scripts/ci/package-tests.sh publish macos-${{ matrix.arch }} ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "macos-${{ matrix.arch }}" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-macos-${{ matrix.arch }} + path: ${{ runner.temp }}/package-tests + + - name: Remove -O2 build directory + id: remove-o2 + if: env.cache_o0 == 'true' && !cancelled() && steps.mac_cli_build.outcome == 'success' + shell: bash + run: find dist-newstyle -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + + + - name: Restore -O0 build directory from cache + id: restore-build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-O0- + + - name: Build at -O0 for pull request caches + id: build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + shell: bash + run: | + scripts/ci/prune-old-builds.sh dist-newstyle + cabal build -j --enable-tests --ghc-options=-O0 + + - name: Save -O0 build directory to cache + if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + # ========================= # Windows Build # ========================= @@ -601,6 +1224,9 @@ jobs: name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] runs-on: ${{ matrix.os }} + concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-windows-${{ matrix.os }}-${{ needs.maybe-release.outputs.libs_tag }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: @@ -616,12 +1242,11 @@ jobs: submodules: recursive - name: Prepare build + id: prepare 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) @@ -640,10 +1265,66 @@ jobs: git perl make + zip pacboy: >- toolchain:p cmake:p + - name: Compute cache key prefixes + id: keys + if: env.release != 'true' + shell: msys2 {0} + run: | + toolchain=$(gcc --version | sha256sum | cut -c1-16) + target=windows-x86_64-ghc${{ matrix.ghc }}-$toolchain + echo "deps=deps-$target" >> "$GITHUB_OUTPUT" + echo "dist=dist-$target" >> "$GITHUB_OUTPUT" + + - name: Restore dependencies from cache + id: restore-deps + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.deps }}-${{ env.branch }}- + ${{ steps.keys.outputs.deps }}- + + - name: Warn if dependencies not in cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.deps }}-${{ env.branch }}" + + - name: Restore source repositories from cache + id: restore-src + if: env.release != 'true' + uses: actions/cache/restore@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + restore-keys: src-${{ runner.os }}- + + - name: Warn if source repositories not in cache + if: env.release != 'true' && steps.restore-src.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::src-${{ runner.os }}" + + - name: Restore build directory from cache + id: restore-build + if: env.release != 'true' && !needs.maybe-release.outputs.libs_tag + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-${{ env.opt }}- + + - name: Warn if build directory not in cache + if: steps.restore-build.outcome == 'success' && steps.restore-build.outputs.cache-matched-key == '' + run: echo "::warning title=Cache miss::${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}" + # rm -rf dist-newstyle/src/{direct-sq,simplexmq}* is here because of the bug in cabal's dependency which prevents second build from finishing # (simplexmq is removed because cabal cannot delete its read-only git submodule pack files - blst, libbbs - on Windows) - name: Build CLI @@ -660,14 +1341,106 @@ jobs: echo " extra-include-dirs: $openssl_windows_style_path\include" >> cabal.project.local echo " extra-lib-dirs: $openssl_windows_style_path" >> cabal.project.local + scripts/ci/prune-old-builds.sh dist-newstyle rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* sed -i "s/, unix /--, unix /" simplex-chat.cabal - cabal build -j --enable-tests + cabal build -j --enable-tests ${{ env.ghc_options }} rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* 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: Compute dependency cache key + id: hash-deps + if: env.release != 'true' + shell: msys2 {0} + run: echo "hash=$(ls "$(cygpath -u '${{ steps.prepare.outputs.cabal-store }}')"/ghc-*/ | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" + + - name: Save dependencies to cache + if: env.release != 'true' && steps.restore-deps.outputs.cache-matched-key != format('{0}-{1}-{2}', steps.keys.outputs.deps, env.branch, steps.hash-deps.outputs.hash) + uses: actions/cache/save@v4 + with: + path: ${{ steps.prepare.outputs.cabal-store }} + key: ${{ steps.keys.outputs.deps }}-${{ env.branch }}-${{ steps.hash-deps.outputs.hash }} + + - name: Save source repositories to cache + if: env.release != 'true' && steps.restore-src.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: dist-newstyle/src + key: src-${{ runner.os }}-${{ hashFiles('cabal.project') }} + + - name: Save build directory to cache + if: env.release != 'true' && steps.restore-build.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-${{ env.opt }}-${{ env.branch }}-${{ github.sha }} + + - name: Build library + if: needs.maybe-release.outputs.libs_tag + shell: msys2 {0} + run: | + export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) + SKIP_VLC=1 scripts/desktop/build-lib-windows.sh + dlltool -d libsimplex.dll.def -l libsimplex.lib -D libsimplex.dll + name=simplex-chat-libs-windows-x86_64 + mkdir -p release-libs/$name/libs + find apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64 -maxdepth 1 -type f -exec cp {} release-libs/$name/libs/ \; + cp libsimplex.lib release-libs/$name/libs/ + (cd release-libs/$name && zip -r ../$name.zip libs) + + - name: Upload library + if: needs.maybe-release.outputs.libs_tag + shell: bash + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + TAG: ${{ needs.maybe-release.outputs.libs_tag }} + run: gh release upload "$TAG" release-libs/*.zip --clobber --repo "${{ github.repository }}-libs" + + - name: Setup Node.js + id: setup-node + if: ${{ !cancelled() && (needs.maybe-release.outputs.addon_draft || needs.maybe-release.outputs.publish) }} + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Setup Python + id: setup-python + if: ${{ !cancelled() && steps.setup-node.outcome == 'success' }} + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Add-on + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.addon_draft }} + timeout-minutes: 30 + shell: bash + env: + LIBS_RELEASE: ${{ needs.maybe-release.outputs.libs_latest }} + ADDON_TAG: ${{ needs.maybe-release.outputs.addon_tag }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + run: scripts/ci/package-tests.sh addon windows-x86_64 + + - name: Package tests + id: package-tests + if: ${{ !cancelled() && steps.setup-python.outcome == 'success' && needs.maybe-release.outputs.publish }} + timeout-minutes: 30 + shell: bash + run: | + scripts/ci/package-tests.sh publish windows-x86_64 ${{ needs.maybe-release.outputs.publish_nodejs && 'nodejs' || '' }} ${{ needs.maybe-release.outputs.publish_python && 'python' || '' }} + echo "windows-x86_64" > "$RUNNER_TEMP/package-tests" + + - name: Package tests result + if: ${{ !cancelled() && steps.package-tests.outcome == 'success' }} + uses: actions/upload-artifact@v7 + with: + name: package-tests-windows-x86_64 + path: ${{ runner.temp }}/package-tests + - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') uses: ./.github/actions/prepare-release @@ -702,109 +1475,210 @@ jobs: github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Remove -O2 build directory + id: remove-o2 + if: env.cache_o0 == 'true' && !needs.maybe-release.outputs.libs_tag && !cancelled() && steps.windows_cli_build.outcome == 'success' + shell: msys2 {0} + run: find dist-newstyle -mindepth 1 -maxdepth 1 ! -name src -exec rm -rf {} + + + - name: Restore -O0 build directory from cache + id: restore-build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + uses: actions/cache/restore@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + restore-keys: | + ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}- + ${{ steps.keys.outputs.dist }}-O0- + + - name: Build at -O0 for pull request caches + id: build-o0 + if: ${{ !cancelled() && steps.remove-o2.outcome == 'success' }} + shell: msys2 {0} + run: | + export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) + scripts/desktop/prepare-openssl-windows.sh + scripts/ci/prune-old-builds.sh dist-newstyle + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + cabal build -j --enable-tests --ghc-options=-O0 + rm -rf dist-newstyle/src/direct-sq* dist-newstyle/src/simplexmq* + + - name: Save -O0 build directory to cache + if: ${{ !cancelled() && steps.build-o0.outcome == 'success' && steps.restore-build-o0.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + dist-newstyle/* + !dist-newstyle/src + key: ${{ steps.keys.outputs.dist }}-O0-${{ env.branch }}-${{ github.sha }} + # ========================= -# NodeJS libs release +# Releases and packages # ========================= -# Downloads Desktop builds, extracts and archives libraries for NodeJS addon. -# Depends on Linux/MacOS, executes only on release. - -# Secrets: -# ------- -# NODEJS_REPO_TOKEN -# Only select repositories: simplex-chat-libs -# Permissions: -# * Contents (Read and Write) - - release-nodejs-libs: + releases: + needs: [maybe-release, build-linux, build-linux-push, build-linux-postgres, build-macos, build-windows] + if: ${{ !cancelled() && (needs.maybe-release.outputs.libs_tag || needs.maybe-release.outputs.addon_draft) }} runs-on: ubuntu-latest - needs: [build-linux, build-linux-postgres, build-macos] - if: startsWith(github.ref, 'refs/tags/v') && (!cancelled()) + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs steps: - - name: Checkout current repository + - name: Checkout Code uses: actions/checkout@v6 - - name: Install packages for archiving - run: sudo apt install -y msitools gcc-mingw-w64 + - name: Publish library release + if: needs.maybe-release.outputs.libs_tag + run: python3 scripts/ci/packages_release.py publish-release "${{ needs.maybe-release.outputs.libs_tag }}" - - name: Download postgres libs artifact - if: needs.build-linux-postgres.result == 'success' - uses: actions/download-artifact@v4 + - name: Publish add-on release + if: ${{ !cancelled() && needs.maybe-release.outputs.addon_draft }} + run: python3 scripts/ci/packages_release.py publish-release "${{ needs.maybe-release.outputs.addon_tag }}" + +# Publishing uses trusted publishing (OIDC) for this workflow on npm and PyPI, +# or the NPM_TOKEN and PYPI_API_TOKEN secrets when they are set. +# Test repositories: variables NPM_SCOPE, PYPI_PROJECT and PYPI_URL / PYPI_UPLOAD_URL (TestPyPI) +# publish under other names. + + publish: + needs: [maybe-release, build-linux, build-linux-push, build-macos, build-windows, releases] + if: ${{ !cancelled() && (needs.maybe-release.outputs.publish || needs.maybe-release.outputs.publish_types) }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + # set by a publish step after it succeeds, so a failed publish still reports the earlier ones + outputs: + types: ${{ steps.types.outputs.version }} + nodejs: ${{ steps.nodejs.outputs.version }} + python: ${{ steps.python.outputs.version }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Download package tests results + if: needs.maybe-release.outputs.publish + uses: actions/download-artifact@v8 with: - name: simplex-libs-linux-postgres-x86_64 - path: ${{ runner.temp }}/postgres-libs + pattern: package-tests-* + path: ${{ runner.temp }}/package-tests - - name: Build archives + - name: Check package tests and pins + if: needs.maybe-release.outputs.publish + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs run: | - INIT_DIR='${{ runner.temp }}/artifacts' - RELEASE_DIR='${{ runner.temp }}/release-assets' - TAG='${{ github.ref_name }}' - URL='https://github.com/${{ github.repository }}/releases/download' - PREFIX='${{ github.event.repository.name }}-libs' - # Windows-specific - FILE_URL='https://raw.githubusercontent.com/${{ github.repository }}/refs/tags/${{ github.ref_name }}' - - # Setup directories - mkdir "$INIT_DIR" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Downlaod desktop release - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-ubuntu-22_04-x86_64.deb" -o linux.deb - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-macos-aarch64.dmg" -o macos-aarch64.dmg - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-macos-x86_64.dmg" -o macos-x86_64.dmg - curl --proto '=https' --tlsv1.2 -sSf -L "${URL}/${TAG}/simplex-desktop-windows-x86_64.msi" -o windows-x86_64.msi - - # Linux - # ----- - # Extract libraries - dpkg-deb -R linux.deb linux-out/ && cd linux-out/opt/simplex/lib/app/resources - # Preprare directory - mkdir libs && cp *.so libs/ - # Archive - zip -r "${PREFIX}-linux-x86_64.zip" libs - # Back to original dir - mv "${PREFIX}-linux-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # MacOS: aarch64 - # -------------- - 7z x macos-aarch64.dmg -omacos1-out/ && cd macos1-out/SimpleX/SimpleX.app/Contents/app/resources/ - mkdir libs && cp *.dylib libs/ - zip -r "${PREFIX}-macos-aarch64.zip" libs - mv "${PREFIX}-macos-aarch64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Macos: x86_64 - # ------------- - 7z x macos-x86_64.dmg -omacos2-out/ && cd macos2-out/SimpleX/SimpleX.app/Contents/app/resources/ - mkdir libs && cp *.dylib libs/ - zip -r "${PREFIX}-macos-x86_64.zip" libs - mv "${PREFIX}-macos-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Windows: x86_64 - # --------------- - msiextract windows-x86_64.msi -C windows-out && cd windows-out/SimpleX/app/resources - - # We need to generate library that exports symbols from Windows dll - curl --proto '=https' --tlsv1.2 -sSf -LO "${FILE_URL}/libsimplex.dll.def" - x86_64-w64-mingw32-dlltool -d libsimplex.dll.def -l libsimplex.lib -D libsimplex.dll - - mkdir libs && cp *.dll *.lib libs/ - zip -r "${PREFIX}-windows-x86_64.zip" libs - mv "${PREFIX}-windows-x86_64.zip" "$RELEASE_DIR" && cd "$INIT_DIR" - - # Linux PostgreSQL (only if postgres build succeeded) - # ------------------------------------------------- - POSTGRES_LIBS='${{ runner.temp }}/postgres-libs' - if [ -d "$POSTGRES_LIBS" ]; then - mkdir -p linux-postgres/libs - cp "${POSTGRES_LIBS}"/*.so linux-postgres/libs/ - cd linux-postgres - zip -r "${PREFIX}-linux-x86_64-postgres.zip" libs - mv "${PREFIX}-linux-x86_64-postgres.zip" "$RELEASE_DIR" && cd "$INIT_DIR" + passed=$(find "$RUNNER_TEMP/package-tests" -mindepth 1 -maxdepth 1 -type d -name 'package-tests-*' | wc -l) + if [ "$passed" -ne 5 ]; then + echo "::error::package tests passed on $passed of 5 platforms" + exit 1 fi + python3 scripts/ci/packages_release.py check-pins - - name: Create release in libs repo and upload artifacts - uses: softprops/action-gh-release@v2 + - name: Versions to publish + id: plan + env: + GH_TOKEN: ${{ github.token }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT" + + - name: Setup Node.js + if: steps.plan.outputs.types_version || steps.plan.outputs.nodejs_version + uses: actions/setup-node@v7 with: - repository: ${{ github.repository }}-libs - tag_name: ${{ github.ref_name }} - files: ${{ runner.temp }}/release-assets/* - token: ${{ secrets.NODEJS_REPO_TOKEN }} + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Setup Python + if: steps.plan.outputs.python_version + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + # Before the test names: the build installs @simplex-chat/types from this repository. + - name: Build and test simplex-chat (npm) + if: steps.plan.outputs.nodejs_version + working-directory: packages/simplex-chat-nodejs + run: | + (cd ../simplex-chat-client/types/typescript && npm install --no-audit --no-fund && npx tsc) + npm install --ignore-scripts --no-save --no-audit --no-fund ../simplex-chat-client/types/typescript + npx tsc && cp src/simplex.* dist + npx jest tests/libs.test.ts tests/api.unit.test.ts + + - name: Use test package names + if: vars.NPM_SCOPE || vars.PYPI_PROJECT + run: python3 scripts/ci/packages_release.py test-names + + - name: Publish @simplex-chat/types + id: types + if: steps.plan.outputs.types_version && needs.maybe-release.outputs.publish_types && needs.releases.result == 'success' + working-directory: packages/simplex-chat-client/types/typescript + env: + VERSION: ${{ steps.plan.outputs.types_version }} + DIST_TAG: ${{ steps.plan.outputs.types_dist_tag }} + run: | + npm install --no-audit --no-fund + npm publish --provenance --access public --tag "$DIST_TAG" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Publish simplex-chat (npm) + id: nodejs + if: steps.plan.outputs.nodejs_version + working-directory: packages/simplex-chat-nodejs + env: + VERSION: ${{ steps.plan.outputs.nodejs_version }} + DIST_TAG: ${{ steps.plan.outputs.nodejs_dist_tag }} + run: | + npm publish --provenance --access public --tag "$DIST_TAG" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build and test simplex-chat (PyPI) + if: steps.plan.outputs.python_version + working-directory: packages/simplex-chat-python + run: | + python -m pip install build -e ".[test]" + python -m pytest -q + python -m build + + - name: Publish simplex-chat (PyPI) + if: steps.plan.outputs.python_version + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/simplex-chat-python/dist + repository-url: ${{ vars.PYPI_UPLOAD_URL || 'https://upload.pypi.org/legacy/' }} + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Report the published PyPI version + id: python + if: steps.plan.outputs.python_version + env: + VERSION: ${{ steps.plan.outputs.python_version }} + run: echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + record: + needs: publish + if: ${{ !cancelled() && (needs.publish.outputs.types || needs.publish.outputs.nodejs || needs.publish.outputs.python) }} + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Record published versions + env: + GH_TOKEN: ${{ secrets.NODEJS_REPO_TOKEN }} + LIBS_REPOSITORY: ${{ github.repository }}-libs + TYPES: ${{ needs.publish.outputs.types }} + NODEJS: ${{ needs.publish.outputs.nodejs }} + PYTHON: ${{ needs.publish.outputs.python }} + run: | + python3 scripts/ci/packages_release.py record --commit "$GITHUB_SHA" \ + --types "$TYPES" --nodejs "$NODEJS" --python "$PYTHON" diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml new file mode 100644 index 0000000000..d1186018aa --- /dev/null +++ b/.github/workflows/desktop-linux.yml @@ -0,0 +1,481 @@ +name: desktop-linux + +# A push runs the jobs only when its head commit's message asks for them: see scripts/ci/desktop-linux/README.md. +on: + push: + branches: + # only f/ branches, so other branches' pull requests don't list skipped runs; elsewhere, run it by hand + - "f/*" + # a squash merge's message lists the merged commits' subjects, with their tags + - "!master" + - "!stable" + workflow_dispatch: + inputs: + claude: + description: Also run the Claude job, which spends the Claude subscription's usage limits + type: boolean + default: false + backend: + description: Also start the test backend + type: boolean + default: false + release: + description: Also test the latest released app, to tell a branch's failure from the runner's + type: boolean + default: false + +permissions: + contents: read + actions: read + +concurrency: + # a push that asks for no run must not cancel one that does + group: desktop-linux-${{ github.ref }}-${{ (github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:desktop]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]')) && 'requested' || github.run_id }} + cancel-in-progress: true + +env: + GHC_VER: 9.6.3 + CABAL_VER: 3.10.2.0 + JAVA_VER: 17 + CORE_PKG: desktop-core-linux-x86_64.tar.gz + BACKEND_PKG: desktop-linux-backend.tar.gz + +jobs: + plan: + name: choose jobs, find earlier builds + # the concurrency group repeats this test, as it can't read job outputs + if: >- + github.event_name == 'workflow_dispatch' || + contains(github.event.head_commit.message, '[app-ci]') || + contains(github.event.head_commit.message, '[app-ci:desktop]') || + contains(github.event.head_commit.message, '[claude-test]') || + contains(github.event.head_commit.message, '[claude-test:desktop]') + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + run-claude: ${{ steps.jobs.outputs.claude }} + run-backend: ${{ steps.jobs.outputs.backend }} + run-release: ${{ steps.jobs.outputs.release }} + claude-backend: ${{ steps.scope.outputs.backend }} + core: ${{ steps.names.outputs.core }} + core-run: ${{ steps.core.outputs.run-id }} + backend: ${{ steps.names.outputs.backend }} + backend-run: ${{ steps.backend.outputs.run-id }} + steps: + - name: Choose jobs + id: jobs + env: + CLAUDE: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:desktop]') }} + BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }} + RELEASE: ${{ inputs.release || contains(github.event.head_commit.message, '[ci-release]') }} + run: | + echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT" + echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT" + echo "release=$RELEASE" | tee -a "$GITHUB_OUTPUT" + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Claude scope + id: scope + if: steps.jobs.outputs.claude == 'true' + env: + BACKEND: ${{ steps.jobs.outputs.backend }} + run: | + backend=$(sed -n 's/^backend: *//p' scripts/ci/desktop-linux/claude-scope) + case "$backend" in + yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;; + *) + echo "::error::claude-scope must say backend: yes or backend: no" + exit 1 + ;; + esac + if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then + echo "::error::claude-scope says the Claude session uses the test backend: ask for it too" + exit 1 + fi + + # artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded + - name: Artifact names + id: names + run: | + echo "core=desktop-linux-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/desktop/build-lib-linux.sh', 'scripts/ci/desktop-linux/build-core.sh') }}" >> "$GITHUB_OUTPUT" + echo "backend=desktop-linux-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/desktop-linux/build-backend.sh') }}" >> "$GITHUB_OUTPUT" + + - name: Find core library + id: core + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.core }}" + + - name: Find backend + id: backend + if: steps.jobs.outputs.backend == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.backend }}" + + backend: + name: test backend for the app + needs: plan + if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == '' + runs-on: ubuntu-24.04 + timeout-minutes: 150 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Show runner + run: | + lsb_release -ds + nproc + free -h + df -h / + + - name: Setup Haskell + uses: simplex-chat/setup-haskell-action@v2 + with: + ghc-version: ${{ env.GHC_VER }} + cabal-version: ${{ env.CABAL_VER }} + + - name: Install libraries + run: | + sudo apt-get update + sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev + + - name: Build backend + run: scripts/ci/desktop-linux/build-backend.sh ~/backend/${{ env.BACKEND_PKG }} + + - name: Upload backend + uses: actions/upload-artifact@v7 + with: + name: ${{ needs.plan.outputs.backend }} + path: ~/backend/${{ env.BACKEND_PKG }} + compression-level: 0 + retention-days: 30 + if-no-files-found: error + + core: + name: core library for Linux + needs: plan + if: needs.plan.outputs.core-run == '' + runs-on: ubuntu-24.04 + timeout-minutes: 150 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Show runner + run: | + lsb_release -ds + nproc + free -h + df -h / + + - name: Setup Haskell + uses: simplex-chat/setup-haskell-action@v2 + with: + ghc-version: ${{ env.GHC_VER }} + cabal-version: ${{ env.CABAL_VER }} + + - name: Install libraries + run: | + sudo apt-get update + sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev patchelf + + - name: Build core library + run: scripts/ci/desktop-linux/build-core.sh ~/core/${{ env.CORE_PKG }} + + - name: Upload core library + uses: actions/upload-artifact@v7 + with: + name: ${{ needs.plan.outputs.core }} + path: ~/core/${{ env.CORE_PKG }} + compression-level: 0 + retention-days: 30 + if-no-files-found: error + + app: + name: desktop app under Xvfb + needs: [plan, core, backend] + # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it + if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Show runner + run: | + lsb_release -ds + nproc + free -h + df -h / + echo "ANDROID_HOME=$ANDROID_HOME" + ls "$ANDROID_HOME/platforms" "$ANDROID_HOME/build-tools" || true + + - name: Install tools + run: | + sudo apt-get update + sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core + dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core + + - name: Download core library + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.core }} + path: ~/core + run-id: ${{ needs.plan.outputs.core-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Install core library + id: core + run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.CORE_PKG }} + + - name: Download backend + if: needs.plan.outputs.run-backend == 'true' + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.backend }} + path: ~/backend + run-id: ${{ needs.plan.outputs.backend-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Start test backend + id: backend + if: needs.plan.outputs.run-backend == 'true' + timeout-minutes: 30 + run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend" + + # the app and its tests are still built when the backend fails + - name: Setup Java + if: ${{ !cancelled() && steps.core.outcome == 'success' }} + uses: actions/setup-java@v5 + with: + distribution: corretto + java-version: ${{ env.JAVA_VER }} + + - name: Build app + id: build + if: ${{ !cancelled() && steps.core.outcome == 'success' }} + working-directory: apps/multiplatform + run: | + ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable + app=release/main/app/simplex + ls -la "$app/bin" "$app/lib/app/resources" + du -sh "$app" + LD_LIBRARY_PATH="$app/lib/app/resources" ldd "$app/lib/app/resources/libapp-lib.so" + + # for the Claude job; a tarball keeps the app's file modes, which an artifact drops + - name: Pack app + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + run: tar -czf "$RUNNER_TEMP/desktop-app.tar.gz" -C apps/multiplatform/release/main/app simplex + + - name: Upload app + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: desktop-app + path: ${{ runner.temp }}/desktop-app.tar.gz + compression-level: 0 + retention-days: 1 + if-no-files-found: error + + - name: Run desktop tests + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + working-directory: apps/multiplatform + run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :common:desktopTest + + - name: Report desktop tests + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: scripts/ci/desktop-linux/test-summary.py apps/multiplatform/common/build/test-results/desktopTest + + - name: Run smoke test + if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome != 'failure' }} + env: + BACKEND: ${{ steps.backend.outcome }} + run: | + if [ "$BACKEND" = success ]; then + export NAMES_SERVER=$(cat "$RUNNER_TEMP/backend/names-server") + cases=backend + fi + scripts/ci/desktop-linux/smoke-test.sh apps/multiplatform/release/main/app/simplex "$RUNNER_TEMP/smoke-test" ${cases:-} + + - name: Stop test backend + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend" + + - name: Upload smoke test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: desktop-smoke-test + path: ${{ runner.temp }}/smoke-test + retention-days: 3 + if-no-files-found: warn + + - name: Upload desktop test report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: desktop-test-report + path: | + apps/multiplatform/common/build/reports/tests/desktopTest + apps/multiplatform/common/build/test-results/desktopTest + retention-days: 3 + if-no-files-found: warn + + - name: Upload backend logs + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + uses: actions/upload-artifact@v7 + with: + name: desktop-backend-logs + path: ${{ runner.temp }}/backend/*.log + retention-days: 3 + if-no-files-found: warn + + # Claude Code repeats the smoke test with the ui helper. Each run spends the Claude subscription's usage + # limits, so it runs only when asked for. The app-sim-claude environment's branch rule decides which branches + # may use the token. + claude: + name: Claude tests the desktop app + needs: [plan, app] + if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + environment: + name: app-sim-claude + deployment: false + concurrency: + group: desktop-linux-claude-${{ github.ref }} + cancel-in-progress: true + env: + # the same in both app workflows + CLAUDE_CODE_VERSION: 2.1.300 + CLAUDE_MODEL: claude-haiku-5-5 + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install tools + run: | + sudo apt-get update + sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core + dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr sqlite3 fonts-dejavu-core + + - name: Download app + uses: actions/download-artifact@v8 + with: + name: desktop-app + path: ${{ runner.temp }} + + - name: Unpack app + run: | + mkdir -p "$RUNNER_TEMP/app" + tar -xzf "$RUNNER_TEMP/desktop-app.tar.gz" -C "$RUNNER_TEMP/app" + + - name: Download backend + if: needs.plan.outputs.claude-backend == 'yes' + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.backend }} + path: ~/backend + run-id: ${{ needs.plan.outputs.backend-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Start test backend + id: backend + if: needs.plan.outputs.claude-backend == 'yes' + timeout-minutes: 30 + run: scripts/ci/desktop-linux/backend.sh start ~/backend/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend" + + # ui goes on every shell's PATH, where Claude's Bash tool finds it whatever environment it builds + - name: Install Claude Code and the ui command + run: | + npm install --prefix "$RUNNER_TEMP/claude-code" "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION" + echo "$RUNNER_TEMP/claude-code/node_modules/.bin" >> "$GITHUB_PATH" + printf '#!/bin/sh\nexec %s/scripts/ci/desktop-linux/ui "$@"\n' "$PWD" | sudo tee /usr/local/bin/ui + sudo chmod +x /usr/local/bin/ui + + - name: Start the app + run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/app/simplex" "$RUNNER_TEMP/claude-test/screens" start + + # Claude's Bash is limited to single ui commands by the guard hook in claude-test.sh. Claude Code's credential + # scrub stays off: on Linux it forces its whole Bash sandbox (bubblewrap, socat), which ui isn't set up for. + - name: Run Claude + id: claude + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: 1 + run: scripts/ci/desktop-linux/claude-test.sh "$RUNNER_TEMP/claude-test" "$RUNNER_TEMP/claude-transcript.jsonl" + + - name: Report Claude usage + if: ${{ !cancelled() && steps.claude.outcome != 'skipped' }} + run: scripts/ci/desktop-linux/claude-usage.py "$RUNNER_TEMP/claude-transcript.jsonl" "$RUNNER_TEMP/claude-usage.json" + + - name: Stop test backend + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + run: scripts/ci/desktop-linux/backend.sh stop "$RUNNER_TEMP/backend" + + - name: Upload Claude test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: desktop-claude-test + path: | + ${{ runner.temp }}/claude-test + ${{ runner.temp }}/claude-transcript.jsonl + ${{ runner.temp }}/claude-transcript.jsonl.stderr + ${{ runner.temp }}/claude-transcript.jsonl.guard.log + ${{ runner.temp }}/claude-usage.json + retention-days: 7 + if-no-files-found: warn + + - name: Upload backend logs + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + uses: actions/upload-artifact@v7 + with: + name: desktop-claude-backend-logs + path: ${{ runner.temp }}/backend/*.log + retention-days: 3 + if-no-files-found: warn + + # the latest release's onboarding, to tell a branch's own failure from the runner's + release: + name: released app under Xvfb + needs: plan + if: needs.plan.outputs.run-release == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + filter: tree:0 + + - name: Download released app + run: scripts/ci/desktop-linux/release-app.sh "$RUNNER_TEMP/release" + + - name: Install UI tools + run: | + sudo apt-get update + sudo apt-get install -y xvfb xdotool openbox imagemagick tesseract-ocr + dpkg-query -W xvfb xdotool openbox imagemagick tesseract-ocr + + - name: Run smoke test + run: scripts/ci/desktop-linux/smoke-test.sh "$RUNNER_TEMP/release/app/opt/simplex" "$RUNNER_TEMP/smoke-test" onboarding + + - name: Upload smoke test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: desktop-release-smoke-test + path: ${{ runner.temp }}/smoke-test + retention-days: 3 + if-no-files-found: warn diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml new file mode 100644 index 0000000000..a275bb82d6 --- /dev/null +++ b/.github/workflows/ios-sim.yml @@ -0,0 +1,412 @@ +name: ios-sim + +on: + push: + branches: + # only f/ branches, so other branches' pull requests don't list skipped runs; elsewhere, run it by hand + - 'f/*' + # a squash merge's message lists the merged commits' subjects, with their tags + - '!master' + - '!stable' + workflow_dispatch: + inputs: + claude: + description: Also run the Claude session + type: boolean + default: false + backend: + description: Start the test backend + type: boolean + default: false + +permissions: + contents: read + actions: read + +concurrency: + # a push that asks for no run must not cancel one that does + group: ios-sim-${{ github.ref }}-${{ (github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, '[app-ci]') || contains(github.event.head_commit.message, '[app-ci:ios]') || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]')) && 'requested' || github.run_id }} + cancel-in-progress: true + +env: + GHC_VER: 9.6.3 + CABAL_VER: 3.10.2.0 + CORE_PKG: pkg-ios-aarch64-swift-json.zip + BACKEND_PKG: ios-sim-backend.zip + HOMEBREW_NO_AUTO_UPDATE: 1 + HOMEBREW_NO_INSTALL_CLEANUP: 1 + # newer images add Command Line Tools whose macOS SDK the default Xcode's linker cannot read + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + +jobs: + plan: + name: choose jobs, find earlier builds + # the concurrency group repeats this test, as it can't read job outputs + if: >- + github.event_name == 'workflow_dispatch' || + contains(github.event.head_commit.message, '[app-ci]') || + contains(github.event.head_commit.message, '[app-ci:ios]') || + contains(github.event.head_commit.message, '[claude-test]') || + contains(github.event.head_commit.message, '[claude-test:ios]') + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + run-claude: ${{ steps.jobs.outputs.claude }} + run-backend: ${{ steps.jobs.outputs.backend }} + claude-backend: ${{ steps.scope.outputs.backend }} + core: ${{ steps.names.outputs.core }} + core-run: ${{ steps.core.outputs.run-id }} + backend: ${{ steps.names.outputs.backend }} + backend-run: ${{ steps.backend.outputs.run-id }} + steps: + - name: Choose jobs + id: jobs + env: + CLAUDE: ${{ inputs.claude || contains(github.event.head_commit.message, '[claude-test]') || contains(github.event.head_commit.message, '[claude-test:ios]') }} + BACKEND: ${{ inputs.backend || contains(github.event.head_commit.message, '[ci-backend]') }} + run: | + echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT" + echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT" + + - name: Checkout code + uses: actions/checkout@v6 + + - name: Claude scope + id: scope + if: steps.jobs.outputs.claude == 'true' + env: + BACKEND: ${{ steps.jobs.outputs.backend }} + run: | + backend=$(sed -n 's/^backend: *//p' scripts/ci/ios-sim/claude-scope) + case "$backend" in + yes | no) echo "backend=$backend" | tee -a "$GITHUB_OUTPUT" ;; + *) + echo "::error::claude-scope must say backend: yes or backend: no" + exit 1 + ;; + esac + if [ "$backend" = yes ] && [ "$BACKEND" != true ]; then + echo "::error::claude-scope says the Claude sessions use the test backend: ask for it too" + exit 1 + fi + + # artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded + - name: Artifact names + id: names + run: | + echo "core=ios-sim-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-core.sh') }}" >> "$GITHUB_OUTPUT" + echo "backend=ios-sim-backend-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'apps/simplex-directory-service/Main.hs', 'apps/simplex-directory-service/src/**', 'cabal.project', 'simplex-chat.cabal', 'scripts/ci/ios-sim/build-backend.sh') }}" >> "$GITHUB_OUTPUT" + + - name: Find core library + id: core + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.core }}" + + - name: Find backend + id: backend + if: steps.jobs.outputs.backend == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/ios-sim/earlier-run.sh "${{ steps.names.outputs.backend }}" + + core: + name: core library for the iOS simulator + needs: plan + if: needs.plan.outputs.core-run == '' + runs-on: macos-26 + timeout-minutes: 240 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Show runner + run: | + sw_vers + sysctl -n hw.ncpu hw.memsize + df -h / + xcodebuild -version + xcrun --show-sdk-path + ls /Library/Developer/CommandLineTools/SDKs || true + + - name: Use the macOS 26 SDK + run: scripts/ci/ios-sim/use-macos26-sdk.sh + + - name: Setup Haskell + uses: simplex-chat/setup-haskell-action@v2 + with: + ghc-version: ${{ env.GHC_VER }} + cabal-version: ${{ env.CABAL_VER }} + + - name: Install libraries + run: brew install openssl@3 libffi gmp + + - name: Build core library + id: build + run: scripts/ci/ios-sim/build-core.sh ~/Downloads/${{ env.CORE_PKG }} + + - name: Patch core library for the simulator + run: scripts/ci/ios-sim/prepare-libs.sh + + - name: Upload core library + uses: actions/upload-artifact@v7 + with: + name: ${{ needs.plan.outputs.core }} + path: ~/Downloads/${{ env.CORE_PKG }} + compression-level: 0 + retention-days: 30 + if-no-files-found: error + + backend: + name: test backend for the app + needs: plan + if: needs.plan.outputs.run-backend == 'true' && needs.plan.outputs.backend-run == '' + runs-on: macos-26 + timeout-minutes: 240 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Show runner + run: | + sw_vers + xcodebuild -version + xcrun --show-sdk-path + ls /Library/Developer/CommandLineTools/SDKs || true + + - name: Use the macOS 26 SDK + run: scripts/ci/ios-sim/use-macos26-sdk.sh + + - name: Setup Haskell + uses: simplex-chat/setup-haskell-action@v2 + with: + ghc-version: ${{ env.GHC_VER }} + cabal-version: ${{ env.CABAL_VER }} + + - name: Build backend + run: scripts/ci/ios-sim/build-backend.sh ~/Downloads/${{ env.BACKEND_PKG }} + + - name: Upload backend + uses: actions/upload-artifact@v7 + with: + name: ${{ needs.plan.outputs.backend }} + path: ~/Downloads/${{ env.BACKEND_PKG }} + compression-level: 0 + retention-days: 30 + if-no-files-found: error + + app: + name: app in the iOS simulator + needs: [plan, core, backend] + # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it + if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }} + runs-on: macos-26 + timeout-minutes: 120 + env: + START_BACKEND: ${{ needs.plan.outputs.run-backend }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Show runner + run: | + sw_vers + df -h / + xcodebuild -version + xcrun --show-sdk-path + ls /Library/Developer/CommandLineTools/SDKs || true + xcrun simctl list runtimes + + - name: Use the macOS 26 SDK + run: scripts/ci/ios-sim/use-macos26-sdk.sh + + - name: Download core library + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.core }} + path: ~/Downloads + run-id: ${{ needs.plan.outputs.core-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Prepare libraries + run: | + scripts/ci/ios-sim/prepare-libs.sh + scripts/ios/update-pbxproj.sh + + - name: Download backend + if: env.START_BACKEND == 'true' + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.backend }} + path: ~/Downloads + run-id: ${{ needs.plan.outputs.backend-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Start test backend + if: env.START_BACKEND == 'true' + timeout-minutes: 30 + run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend" + + - name: Build app + run: | + xcodebuild build -quiet \ + -project apps/ios/SimpleX.xcodeproj \ + -scheme "SimpleX (iOS)" \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$RUNNER_TEMP/DerivedData" \ + -onlyUsePackageVersionsFromResolvedFile \ + -skipPackagePluginValidation \ + -skipMacroValidation \ + ARCHS=arm64 \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY=- \ + DEVELOPMENT_TEAM= \ + PROVISIONING_PROFILE_SPECIFIER= + + # for the Claude job; a tarball keeps the app's file modes, which an artifact drops + - name: Pack app + if: needs.plan.outputs.run-claude == 'true' + run: tar -czf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator" SimpleX.app + + - name: Upload app + if: needs.plan.outputs.run-claude == 'true' + uses: actions/upload-artifact@v7 + with: + name: ios-sim-app + path: ${{ runner.temp }}/SimpleX.app.tar.gz + compression-level: 0 + retention-days: 1 + if-no-files-found: error + + - name: Install AXe + run: scripts/ci/ios-sim/install-axe.sh + + - name: Run smoke test + run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator/SimpleX.app" "$RUNNER_TEMP/smoke-test" + + - name: Stop test backend + if: ${{ !cancelled() && env.START_BACKEND == 'true' }} + run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend" + + - name: Upload smoke test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: ios-sim-smoke-test + path: ${{ runner.temp }}/smoke-test + retention-days: 3 + if-no-files-found: warn + + - name: Upload backend logs + if: ${{ !cancelled() && env.START_BACKEND == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: ios-sim-backend-logs + path: | + ${{ runner.temp }}/backend/*.log + ${{ runner.temp }}/backend/seed.md + retention-days: 3 + if-no-files-found: warn + + # The app-sim-claude environment's branch rule decides which branches may use the token. + claude: + name: Claude tests the iOS app + needs: [plan, app] + if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }} + runs-on: macos-26 + timeout-minutes: 330 + environment: + name: app-sim-claude + deployment: false + concurrency: + group: ios-sim-claude-${{ github.ref }} + cancel-in-progress: true + env: + # the same in both app workflows + CLAUDE_CODE_VERSION: 2.1.300 + steps: + # the job holds the Claude token, so the checkout's GitHub token is not left in .git/config + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Show runner + run: | + sw_vers + xcodebuild -version + xcrun simctl list runtimes + + - name: Use the macOS 26 SDK + run: scripts/ci/ios-sim/use-macos26-sdk.sh + + - name: Download app + uses: actions/download-artifact@v8 + with: + name: ios-sim-app + path: ${{ runner.temp }} + + - name: Unpack app + run: tar -xzf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP" + + - name: Download backend + if: needs.plan.outputs.claude-backend == 'yes' + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.backend }} + path: ~/Downloads + run-id: ${{ needs.plan.outputs.backend-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Start test backend + id: backend + if: needs.plan.outputs.claude-backend == 'yes' + timeout-minutes: 30 + run: scripts/ci/ios-sim/backend.sh start ~/Downloads/${{ env.BACKEND_PKG }} "$RUNNER_TEMP/backend" + + - name: Install AXe + run: scripts/ci/ios-sim/install-axe.sh + + - name: Run smoke test + run: scripts/ci/ios-sim/smoke-test.sh "$RUNNER_TEMP/SimpleX.app" "$RUNNER_TEMP/claude-test/smoke-test" + + - name: Install Claude Code + run: | + npm install -g "@anthropic-ai/claude-code@$CLAUDE_CODE_VERSION" + claude --version + + - name: Run the device test plan with Claude + timeout-minutes: 240 + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + BACKEND_DIR: ${{ steps.backend.outcome == 'success' && format('{0}/backend', runner.temp) || '' }} + run: scripts/ci/ios-sim/claude-test.sh "$RUNNER_TEMP/claude-test" + + - name: Report Claude usage + if: ${{ always() }} + run: scripts/ci/ios-sim/claude-usage.sh "$RUNNER_TEMP/claude-test" + + - name: Stop test backend + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + run: scripts/ci/ios-sim/backend.sh stop "$RUNNER_TEMP/backend" + + - name: Upload Claude test results + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: ios-sim-claude-test + path: ${{ runner.temp }}/claude-test + retention-days: 14 + if-no-files-found: warn + + - name: Upload backend logs + if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} + uses: actions/upload-artifact@v7 + with: + name: ios-sim-claude-backend-logs + path: | + ${{ runner.temp }}/backend/*.log + ${{ runner.temp }}/backend/seed.md + retention-days: 3 + if-no-files-found: warn diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml new file mode 100644 index 0000000000..e5ed17c4f8 --- /dev/null +++ b/.github/workflows/packages.yml @@ -0,0 +1,74 @@ +name: packages + +on: + pull_request: + paths: + - "packages/simplex-chat-nodejs/**" + - "packages/simplex-chat-client/types/typescript/**" + - "packages/simplex-chat-python/**" + - ".github/workflows/packages.yml" + +concurrency: + # build.yml and this workflow both run for a pull request that changes the core and the packages + group: ${{ github.workflow }}-packages-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + NPM_SCOPE: ${{ vars.NPM_SCOPE }} + PYPI_PROJECT: ${{ vars.PYPI_PROJECT }} + PYPI_URL: ${{ vars.PYPI_URL }} + +jobs: + addon: + runs-on: ubuntu-latest + outputs: + platforms: ${{ steps.plan.outputs.platforms }} + test: ${{ steps.plan.outputs.test }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Build plan + id: plan + env: + BASE: ${{ github.event.pull_request.base.sha }} + run: | + git fetch -q --depth=1 origin "$BASE" + python3 scripts/ci/packages_release.py build-plan --base "$BASE" >> "$GITHUB_OUTPUT" + + build: + name: "build ${{ matrix.platform }}" + needs: addon + if: needs.addon.outputs.platforms != '[]' + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.addon.outputs.platforms) }} + defaults: + run: + shell: bash + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Build simplex-chat (npm) and add-on + run: scripts/ci/package-tests.sh build ${{ matrix.platform }} + + - name: Test simplex-chat (npm and PyPI) + if: needs.addon.outputs.test + run: scripts/ci/package-tests.sh test diff --git a/.gitignore b/.gitignore index 4ceea4d1a1..1bb18ef356 100644 --- a/.gitignore +++ b/.gitignore @@ -89,9 +89,3 @@ apps/android/app/release apps/multiplatform/.kotlin/sessions # anywhere in the tree: this file holds a real BTCPay api_key and webhook_secret badge_service.ini - -# Badge service Docker deployment: the Dockerfile, compose, and example config are tracked; the -# live config (a secret) and its data volume are not. -scripts/badge-service/badge_service.ini -scripts/badge-service/data/ -scripts/badge-service/web/ diff --git a/README.md b/README.md index fa4247caf8..cb792e84f5 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ SimpleX Chat founder - [Privacy and security: technical details and limitations](#privacy-and-security-technical-details-and-limitations) - [For developers](#for-developers) - [Develop a chat bot](#develop-a-chat-bot) +- [Agents](#agents) - [Roadmap](#roadmap) - [Disclaimers, Security contact, License](#disclaimers) @@ -331,6 +332,18 @@ See [our new bot API reference](./bots/README.md). Most of it is automatically g Also see [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js). +## Agents + +- For AI assistants: [simplex.chat/llms.txt](https://simplex.chat/llms.txt), an index of the documentation in Markdown, and [SimpleX for AI agents and developers](https://simplex.chat/llms/agents.md). +- Every page of simplex.chat is also available as Markdown: replace `.html` with `.md` ([simplex.chat/docs/simplex.md](https://simplex.chat/docs/simplex.md)), or add `.md` to the path ([simplex.chat/why.md](https://simplex.chat/why.md)). +- Libraries: [Node.js](./packages/simplex-chat-nodejs/) ([npm](https://www.npmjs.com/package/simplex-chat)) and [Python](./packages/simplex-chat-python/) ([PyPI](https://pypi.org/project/simplex-chat/)). +- [Bot API guide](./bots/README.md) and [API reference](./bots/api/README.md): commands, events and types. +- Example bots: [support bot](./apps/simplex-support-bot/) and [calculator](./apps/simplex-calculator-bot/) in TypeScript, [support bot light](./apps/simplex-support-bot-light/) in Python, [directory service](./apps/simplex-directory-service/) and [broadcast bot](./apps/simplex-broadcast-bot/) in Haskell. +- [Terminal client](./docs/CLI.md): installation, building from source and usage. +- Servers: [SMP](./docs/SERVER.md), [XFTP](./docs/XFTP-SERVER.md) and [chat relay](./docs/CHAT-RELAY.md). +- Protocols: [SimpleX network](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md), [chat](./docs/protocol/simplex-chat.md), [channels](./docs/protocol/channels-overview.md) and [public names](./docs/protocol/names-overview.md). +- For coding agents working in this repository: [project structure](./docs/contributing/PROJECT.md) and [coding and building](./docs/contributing/CODE.md). + ## Roadmap - ✅ Easy to deploy SimpleX server with in-memory message storage, without any dependencies. diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 22d0da3829..32dffbf2e2 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -461,9 +461,14 @@ struct ContentView: View { } func connectViaUrl_(_ url: URL) { + // an app link must not tear down a redemption in flight + if isAppLink(url) && isBadgeLinkIssuing() { return } dismissAllSheets() { var path = url.path - if path == "/r" { + if isAppLink(url) { + // branched on the scheme before the connection dispatch, which an app link must never reach + openAppLink(url) + } else if path == "/r" { showAlert( NSLocalizedString("Relay address", comment: "alert title"), message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") @@ -498,6 +503,24 @@ struct ContentView: View { } } +// the app's own scheme, for every link that is not a connection link, which stays on simplex: +private let appLinkScheme = "simplexchat" +private let badgeLinkPath = "/badge/code/" + +func isAppLink(_ url: URL) -> Bool { + url.scheme?.lowercased() == appLinkScheme +} + +// a link type added in a later version reaches this build too, so an unknown path asks for an update +private func openAppLink(_ url: URL) { + let path = url.path + if path.hasPrefix(badgeLinkPath) { + openBadgeLink(String(path.dropFirst(badgeLinkPath.count))) + } else { + AlertManager.shared.showAlert(Alert(title: Text("This link is not supported by this app version. Please check for app updates."))) + } +} + final class AlertManager: ObservableObject { static let shared = AlertManager() @Published var presentAlert = false diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index eadca9f71e..6af17e3fb8 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -193,6 +193,8 @@ enum ChatCommand: ChatCmdProtocol { case apiStandaloneFileInfo(url: String) // badges case apiRedeemBadgeCode(userId: Int64, code: String) + case apiPurchaseBadge(userId: Int64, echoedInvoiceId: String?, payment: ServicePayment) + case apiCreateBadgeInvoice(userId: Int64) case apiGetBadgeState(userId: Int64) case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64) case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String) @@ -419,6 +421,9 @@ enum ChatCommand: ChatCmdProtocol { case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" case let .apiStandaloneFileInfo(link): return "/_download info \(link)" case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)" + case let .apiPurchaseBadge(userId, echoedInvoiceId, payment): + return "/_badge purchase \(userId)\(echoedInvoiceId.map { " invoice=\($0)" } ?? "") \(encodeJSON(payment))" + case let .apiCreateBadgeInvoice(userId): return "/_badge invoice \(userId)" case let .apiGetBadgeState(userId): return "/_badge state \(userId)" case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)" case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode): @@ -611,6 +616,8 @@ enum ChatCommand: ChatCmdProtocol { case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" case .apiRedeemBadgeCode: return "apiRedeemBadgeCode" + case .apiPurchaseBadge: return "apiPurchaseBadge" + case .apiCreateBadgeInvoice: return "apiCreateBadgeInvoice" case .apiGetBadgeState: return "apiGetBadgeState" case .apiGetBadgeLedger: return "apiGetBadgeLedger" case .apiAckBadgeAlert: return "apiAckBadgeAlert" @@ -667,9 +674,11 @@ enum ChatCommand: ChatCmdProtocol { return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) case let .testStorageEncryption(key): return .testStorageEncryption(key: obfuscate(key)) - // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + // a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString case let .apiRedeemBadgeCode(userId, code): return .apiRedeemBadgeCode(userId: userId, code: obfuscate(code)) + case let .apiPurchaseBadge(userId, echoedInvoiceId, .apple(jws)): + return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: .apple(jws: obfuscate(jws))) default: return self } } @@ -719,6 +728,29 @@ enum ChatCommand: ChatCmdProtocol { } } +enum ServicePayment: Encodable { + case apple(jws: String) + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .apple(jws): + try container.encode("apple", forKey: .type) + try container.encode(jws, forKey: .jws) + } + } + + private enum CodingKeys: String, CodingKey { + case type, jws + } +} + +struct OpenStorePurchase: Decodable { + var invoiceId: String? + var transactionRef: String? + var creditError: BadgeIssueFailure? +} + // ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. // Spec: spec/api.md#ChatResponse0 enum ChatResponse0: Decodable, ChatAPIResult { @@ -1051,7 +1083,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { // badges // the full user, not UserRef: its profile carries the badge that setUserBadge just stored case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?) - case badgeState(user: UserRef, badgeState: BadgeState?) + case badgeInvoice(user: UserRef, invoiceId: String) + case badgeState(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]?) case badgeLedger(user: UserRef, badgeLedger: [StatementEntry]) var responseType: String { @@ -1105,6 +1138,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .archiveImported: "archiveImported" case .appSettings: "appSettings" case .badgeRedeemed: "badgeRedeemed" + case .badgeInvoice: "badgeInvoice" case .badgeState: "badgeState" case .badgeLedger: "badgeLedger" } @@ -1161,7 +1195,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .archiveImported(archiveErrors): return String(describing: archiveErrors) case let .appSettings(appSettings): return String(describing: appSettings) case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))") - case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState)) + case let .badgeInvoice(u, invoiceId): return withUser(u, invoiceId) + case let .badgeState(u, badgeState, storePurchases): return withUser(u, "\(String(describing: badgeState))\nstorePurchases: \(String(describing: storePurchases ?? []))") case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger)) } } @@ -1191,6 +1226,7 @@ enum ChatEvent: Decodable, ChatAPIResult { // group events case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case receivedGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) + case receivedGroupInvitationViaGroup(user: UserRef, groupInfo: GroupInfo, viaGroupInfo: GroupInfo, viaMember: GroupMember, memberRole: GroupMemberRole) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case businessLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember, fromContact: Contact) @@ -1248,6 +1284,8 @@ enum ChatEvent: Decodable, ChatAPIResult { // badges case badgeChanged(user: User, badgeState: BadgeState?) case badgeAlert(user: UserRef, badgeAlert: BadgeAlert) + case storePurchaseCredited(user: UserRef, invoiceId: String) + case storePurchaseRefused(user: UserRef, invoiceId: String, refusal: BadgeIssueFailure) var responseType: String { switch self { @@ -1272,6 +1310,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .chatItemsDeleted: "chatItemsDeleted" case .groupChatItemsDeleted: "groupChatItemsDeleted" case .receivedGroupInvitation: "receivedGroupInvitation" + case .receivedGroupInvitationViaGroup: "receivedGroupInvitationViaGroup" case .userAcceptedGroupSent: "userAcceptedGroupSent" case .groupLinkConnecting: "groupLinkConnecting" case .businessLinkConnecting: "businessLinkConnecting" @@ -1322,6 +1361,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case .contactPQEnabled: "contactPQEnabled" case .badgeChanged: "badgeChanged" case .badgeAlert: "badgeAlert" + case .storePurchaseCredited: "storePurchaseCredited" + case .storePurchaseRefused: "storePurchaseRefused" } } @@ -1356,6 +1397,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_): return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))") case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") + case let .receivedGroupInvitationViaGroup(u, groupInfo, viaGroupInfo, viaMember, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nviaGroupInfo: \(viaGroupInfo)\nviaMember: \(viaMember)\nmemberRole: \(memberRole)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") case let .businessLinkConnecting(u, groupInfo, hostMember, fromContact): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))\nfromContact: \(String(describing: fromContact))") @@ -1406,6 +1448,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") case let .badgeChanged(u, badgeState): return withUser(u, String(describing: badgeState)) case let .badgeAlert(u, badgeAlert): return withUser(u, String(describing: badgeAlert)) + case let .storePurchaseCredited(u, invoiceId): return withUser(u, "invoiceId: \(invoiceId)") + case let .storePurchaseRefused(u, invoiceId, refusal): return withUser(u, "invoiceId: \(invoiceId)\nrefusal: \(refusal.tag)") } } } @@ -1931,10 +1975,13 @@ enum UserServersError: Decodable { case storageMissing(protocol: ServerProtocol, user: UserRef?) case proxyMissing(protocol: ServerProtocol, user: UserRef?) case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) + case tooManyHosts(protocol: ServerProtocol, tooManyHostsServer: String) case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String) var globalError: String? { switch self { + case .tooManyHosts: + return globalSMPError case let .noServers(`protocol`, _): switch `protocol` { case .smp: return globalSMPError @@ -1977,6 +2024,8 @@ enum UserServersError: Decodable { } else { return text } + case let .tooManyHosts(.smp, server): + return String.localizedStringWithFormat(NSLocalizedString("Server %@ has more than two host names.", comment: "servers error"), serverHostname(server)) default: return nil } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 20d8bff34e..cd833e75ff 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -474,10 +474,27 @@ final class ChatModel: ObservableObject { // another in its place; only the badge alert shows regardless. Set while rendering, so not published. var chatListBanner: ChatListBanner? + var fileToOpen: (fileId: Int64, chatId: String?, open: (CIFile) -> Void)? + func bannerSlotFree(for banner: ChatListBanner) -> Bool { chatListBanner == nil || chatListBanner == banner } + func openWhenReceived(_ file: CIFile, open: @escaping (CIFile) -> Void) { + fileToOpen = (fileId: file.fileId, chatId: chatId, open: open) + } + + func receivedFileToOpen(_ file: CIFile) -> Bool { + if let f = fileToOpen { f.fileId == file.fileId && f.chatId == chatId } else { false } + } + + func openReceivedFile(_ file: CIFile) { + if let f = fileToOpen, receivedFileToOpen(file) { + fileToOpen = nil + f.open(file) + } + } + static let shared = ChatModel() let im = ItemsModel.shared @@ -935,6 +952,8 @@ final class ChatModel: ObservableObject { func updateCurrentUser(_ newProfile: Profile, _ preferences: FullPreferences? = nil) { if let current = currentUser { currentUser?.profile = toLocalProfile(current.profile.profileId, newProfile, "") + currentUser?.profile.localBadge = current.profile.localBadge + currentUser?.profile.contactDomainVerified = current.profile.contactDomainVerified if let preferences = preferences { currentUser?.fullPreferences = preferences } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index b6e264f60f..be34cbc294 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1362,8 +1362,12 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? { switch r { case .result(.userProfileNoChange): return (profile, []) case let .result(.userProfileUpdated(_, _, toProfile, updateSummary)): return (toProfile, updateSummary.changedContacts) - case .error(.errorStore(.duplicateName)): return nil; - default: throw r.unexpected + case .error(.errorStore(.duplicateName)), .error(.error(.userExists)): return nil; + default: + await MainActor.run { + showErrorAlert(r.unexpected, NSLocalizedString("Error saving profile", comment: "alert title")) + } + throw r.unexpected } } @@ -1849,8 +1853,8 @@ func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandida try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer)) } -func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws { - let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates) +func apiSendCallExtraInfo(_ contact: Contact, _ rtcSession: String?, _ rtcIceCandidates: String) async throws { + let extraInfo = WebRTCExtraInfo(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates) try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo)) } @@ -2200,13 +2204,42 @@ func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: throw r.unexpected } +// a refusal is thrown, as BREServiceError with the code +enum BadgePurchaseResult { + case held(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) + case credited(user: User, badgeState: BadgeState?) +} + +// log: false because a store receipt is a bearer secret, like a badge code - it is in the command. +func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, _ payment: ServicePayment) async throws -> BadgePurchaseResult { + let r: ChatResponse2 = try await chatSendCmd(.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: payment), log: false) + switch r { + case let .badgeState(user, badgeState, storePurchases): return .held(user: user, badgeState: badgeState, storePurchases: storePurchases ?? []) + case let .badgeRedeemed(user, _, _, badgeState): return .credited(user: user, badgeState: badgeState) + default: throw r.unexpected + } +} + +func apiCreateBadgeInvoice(_ userId: Int64) async throws -> String { + let r: ChatResponse2 = try await chatSendCmd(.apiCreateBadgeInvoice(userId: userId)) + if case let .badgeInvoice(_, invoiceId) = r { return invoiceId } + throw r.unexpected +} + // localized where the user can act on it; otherwise the error itself, so a screenshot says what happened -func redeemErrorText(_ error: Error) -> String { +func redeemErrorText(_ error: Error, purchase: Bool) -> String { if case let .error(.badgeRedeemError(e)) = error as? ChatError { switch e { case .invalidCode: return NSLocalizedString("This code is not valid.", comment: "alert message") - case .serviceNotConfigured: return NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") - case .badgeActive: return NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") + case .invalidReceipt: break + case .serviceNotConfigured: + return purchase + ? NSLocalizedString("This app version cannot buy badges.", comment: "alert message") + : NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") + case .badgeActive: + return purchase + ? NSLocalizedString("This profile already has a badge.", comment: "alert message") + : NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") case let .serviceError(code): if let text = badgeServiceErrorText(code) { return text } case let .invalidResponse(message): return String.localizedStringWithFormat(NSLocalizedString("The badge service sent an unexpected response: %@", comment: "alert message"), message) @@ -2216,15 +2249,15 @@ func redeemErrorText(_ error: Error) -> String { return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error)) } -func apiGetBadgeState(_ userId: Int64) async throws -> BadgeState? { +func apiGetBadgeState(_ userId: Int64) async throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) { let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeState(userId: userId)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) } throw r.unexpected } -func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? { +func apiGetBadgeStateSync(_ userId: Int64) throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) { let r: ChatResponse2 = try chatSendCmdSync(.apiGetBadgeState(userId: userId)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) } throw r.unexpected } @@ -2236,7 +2269,7 @@ func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws - func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? { let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode)) - if case let .badgeState(_, badgeState) = r { return badgeState } + if case let .badgeState(_, badgeState, _) = r { return badgeState } throw r.unexpected } @@ -2365,6 +2398,11 @@ func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws ChatReceiver.shared.start() m.chatRunning = true chatLastStartGroupDefault.set(Date.now) + Task { await BadgeStore.shared.presentUnfinished() } + if shouldDeleteDatabaseBackupsDefault.get() { + deleteDatabaseBackups() + shouldDeleteDatabaseBackupsDefault.set(false) + } } func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? { @@ -2389,6 +2427,7 @@ private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws { m.currentUser = try apiSetActiveUser(userId, viewPwd: viewPwd) m.users = try listUsers() try getUserChatData() + Task { await BadgeStore.shared.presentUnfinished() } } func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: String? = nil) async throws { @@ -2404,6 +2443,7 @@ func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: S m.users = users } try await getUserChatDataAsync(keepingChatId: keepingChatId) + Task { await BadgeStore.shared.presentUnfinished() } await MainActor.run { if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) { invitation.user = currentUser @@ -2431,22 +2471,32 @@ func getUserChatData() throws { private func loadBadgeState() { do { let userId = try currentUserId("loadBadgeState") - let badgeState = try apiGetBadgeStateSync(userId) + let (badgeState, storePurchases) = try apiGetBadgeStateSync(userId) BadgeModel.shared.set(userId: userId, badgeState: badgeState) + BadgeStore.shared.setStorePurchases(userId, storePurchases) } catch let error { logger.error("loadBadgeState: \(responseError(error))") } } -private func loadBadgeStateAsync(_ userId: Int64) async { +func loadBadgeStateAsync(_ userId: Int64) async { do { - let badgeState = try await apiGetBadgeState(userId) - await MainActor.run { BadgeModel.shared.set(userId: userId, badgeState: badgeState) } + let (badgeState, storePurchases) = try await apiGetBadgeState(userId) + await MainActor.run { + BadgeModel.shared.set(userId: userId, badgeState: badgeState) + BadgeStore.shared.setStorePurchases(userId, storePurchases) + } } catch let error { logger.error("loadBadgeState: \(responseError(error))") } } +func loadCurrentBadgeState() async { + if let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) { + await loadBadgeStateAsync(userId) + } +} + private func getUserChatDataAsync(keepingChatId: String?) async throws { let m = ChatModel.shared let tm = ChatTagsModel.shared @@ -2703,7 +2753,8 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_): await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_) - case let .receivedGroupInvitation(user, groupInfo, _, _): + case let .receivedGroupInvitation(user, groupInfo, _, _), + let .receivedGroupInvitationViaGroup(user, groupInfo, _, _, _): if active(user) { await MainActor.run { m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated @@ -2870,6 +2921,9 @@ func processReceivedMsg(_ res: ChatEvent) async { await chatItemSimpleUpdate(user, aChatItem) case let .rcvFileComplete(user, aChatItem): await chatItemSimpleUpdate(user, aChatItem) + if active(user), let file = aChatItem.chatItem.file, m.receivedFileToOpen(file) { + await MainActor.run { m.openReceivedFile(file) } + } case let .rcvFileSndCancelled(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) Task { cleanupFile(aChatItem) } @@ -2916,23 +2970,19 @@ func processReceivedMsg(_ res: ChatEvent) async { m.callInvitations[invitation.contact.id] = invitation } activateCall(invitation) - case let .callOffer(_, contact, callType, offer, sharedKey, _): + case let .callOffer(_, contact, callType, offer, sharedKey, askConfirmation): await withCall(contact) { call in await MainActor.run { call.callState = .offerReceived call.sharedKey = sharedKey } - let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) - let iceServers = getIceServers() - logger.debug(".callOffer useRelay \(useRelay)") - logger.debug(".callOffer iceServers \(String(describing: iceServers))") - await m.callCommand.processCommand(.offer( - offer: offer.rtcSession, - iceCandidates: offer.rtcIceCandidates, - media: callType.media, aesKey: sharedKey, - iceServers: iceServers, - relay: useRelay - )) + if askConfirmation { + showUnencryptedCallAlert(call) { + Task { await processCallOffer(callType, offer, sharedKey) } + } + } else { + await processCallOffer(callType, offer, sharedKey) + } } case let .callAnswer(_, contact, answer): await withCall(contact) { call in @@ -2943,7 +2993,7 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .callExtraInfo(_, contact, extraInfo): await withCall(contact) { _ in - await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates)) + await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates, session: extraInfo.rtcSession)) } case let .callEnded(_, contact): if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) { @@ -3058,6 +3108,10 @@ func processReceivedMsg(_ res: ChatEvent) async { BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert) } } + case let .storePurchaseCredited(user, invoiceId): + Task { await BadgeStore.shared.storePurchaseCredited(user, invoiceId) } + case let .storePurchaseRefused(user, invoiceId, refusal): + Task { await BadgeStore.shared.storePurchaseRefused(user, invoiceId, refusal) } default: logger.debug("unsupported event: \(res.responseType)") } @@ -3069,6 +3123,43 @@ func processReceivedMsg(_ res: ChatEvent) async { logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)") } } + + func processCallOffer(_ callType: CallType, _ offer: WebRTCSession, _ sharedKey: String?) async { + let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY) + let iceServers = getIceServers() + logger.debug(".callOffer useRelay \(useRelay)") + logger.debug(".callOffer iceServers \(String(describing: iceServers))") + await m.callCommand.processCommand(.offer( + offer: offer.rtcSession, + iceCandidates: offer.rtcIceCandidates, + media: callType.media, aesKey: sharedKey, + iceServers: iceServers, + relay: useRelay + )) + } + + func showUnencryptedCallAlert(_ call: Call, onContinue: @escaping () -> Void) { + DispatchQueue.main.async { + showAlert( + NSLocalizedString("Call is not encrypted", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("%@ accepted the call without end-to-end encryption.", comment: "alert message"), call.contact.displayName), + actions: {[ + UIAlertAction(title: NSLocalizedString("End call", comment: "alert action"), style: .destructive) { _ in + // the call may have ended, or a new one started with the same contact, while the alert was shown + guard m.activeCall === call else { return } + if let uuid = call.callUUID { + CallController.shared.endCall(callUUID: uuid) + } else { + CallController.shared.endCall(call: call) {} + } + }, + UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in + if m.activeCall === call { onContinue() } + } + ]} + ) + } + } } func switchToLocalSession() { diff --git a/apps/ios/Shared/SharedAssets.xcassets/Contents.json b/apps/ios/Shared/SharedAssets.xcassets/Contents.json new file mode 100644 index 0000000000..4aa7c5350b --- /dev/null +++ b/apps/ios/Shared/SharedAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/Contents.json b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/Contents.json new file mode 100644 index 0000000000..300553af5f --- /dev/null +++ b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "agent.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/agent.svg b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/agent.svg new file mode 100644 index 0000000000..fd0fdb485a --- /dev/null +++ b/apps/ios/Shared/SharedAssets.xcassets/agent.imageset/agent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 835436bb10..05eddb0a1a 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -43,7 +43,8 @@ struct SimpleXApp: App { .environmentObject(chatModel) .environmentObject(AppTheme.shared) .onOpenURL { url in - logger.debug("ContentView.onOpenURL: \(url)") + // an app link can carry a secret, such as a badge code that anyone who reads it can redeem + logger.debug("ContentView.onOpenURL: \(isAppLink(url) ? "app link" : url.absoluteString)") if AppChatState.shared.value == .active { chatModel.appOpenUrl = url } else { @@ -100,6 +101,7 @@ struct SimpleXApp: App { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { await updateCallInvitations() } + Task { await BadgeStore.shared.presentUnfinished() } if let url = chatModel.appOpenUrlLater { await MainActor.run { chatModel.appOpenUrlLater = nil diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 8d092bbc19..0351884085 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -9,6 +9,7 @@ import Foundation import Combine import StoreKit +import SimpleXChat // TODO [badges] product ids will come from app config and prices from the badge service catalog; // hardcoded here so the App Store integration can be tested before the purchase API lands. @@ -27,11 +28,27 @@ let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in BadgePeriod.allCases.map { badgeProductId(level, $0) } } -// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. -// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction, -// which is how the service learns which invoice a store transaction settles. +// the only products sent to the badge service: nothing delivers a subscription yet +let badgeOneTimeProductIds: Set = Set(BadgeLevel.allCases.map { badgeProductId($0, .oneMonth) }) + +// subscriptions are for sale once renewals are delivered +let badgePeriodsForSale: [BadgePeriod] = [.oneMonth] + +// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core. +// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction. func newBadgeInvoiceId() -> UUID { UUID() } +// the page's app flag rides in the fragment, which never reaches the service +let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true" +// the dev deployment, for testing store purchases: swap with the line above +// let badgePageUrl = "https://smp7.simplex.im/#/tier?app=true" + +// where the store allows a link out to the badge page: the US for now, a set expected to widen. +// An unknown storefront does not count, as this decides whether the store sees a link out of the app. +var badgeBrowserAllowed: Bool { + SKPaymentQueue.default().storefront?.countryCode == "USA" +} + enum BadgePrice { case loading case price(String) @@ -49,21 +66,32 @@ struct BadgeStoreReceipt { // the signed token the badge service verifies - never transaction.jsonRepresentation let jws: String let productId: String - let transactionId: UInt64 let invoiceId: UUID? - let environment: String? let signatureVerified: Bool + let transaction: Transaction + + var echoedInvoiceId: String? { invoiceId.map(coreInvoiceId) } } +// core mints the id in lower case, and UUID formats it in upper case +func coreInvoiceId(_ invoiceId: UUID) -> String { invoiceId.uuidString.lowercased() } + enum BadgePurchaseOutcome { case purchased(BadgeStoreReceipt) - case pending + case pending(invoiceId: String?) case cancelled } +enum BadgePurchaseState { + case issuing + case waitingForApproval +} + enum BadgeStoreError: Error { case productUnavailable(productId: String) case unknownPurchaseResult + case noActiveProfile + case invalidInvoiceId(String) } final class BadgeStore: ObservableObject { @@ -73,14 +101,53 @@ final class BadgeStore: ObservableObject { @Published private var state: LoadState = .notLoaded private var products: [String: Product] = [:] + // core's open store purchases for the profile they were read for: core knows whose a purchase is + @Published private var storePurchases: (userId: Int64, purchases: [OpenStorePurchase])? = nil + // whether a store sheet this run opened has not returned + @Published private var buying = false + // kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing + // by invoice id, so a pending purchase shows only under the profile whose record it names + @Published private var waitingForApproval: Set = [] + // set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was + // closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again + @Published private var reconciledOnce = false + // the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run + private var sweepFailureShown = false + private var transactionUpdates: Task? = nil private init() {} - func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice { + func purchaseState(_ userId: Int64?) -> BadgePurchaseState? { + let purchases = openStorePurchases(userId) + if purchases.contains(where: { $0.transactionRef != nil }) { return .issuing } + if purchases.contains(where: { $0.invoiceId.map(waitingForApproval.contains) == true }) { return .waitingForApproval } + return nil + } + + func creditError(_ userId: Int64?) -> BadgeIssueFailure? { + openStorePurchases(userId).compactMap(\.creditError).first + } + + var checkingPurchases: Bool { !reconciledOnce } + + func canBuy(_ userId: Int64?) -> Bool { + reconciledOnce && !buying && purchaseState(userId) == nil + } + + func setStorePurchases(_ userId: Int64, _ purchases: [OpenStorePurchase]) { + storePurchases = (userId, purchases) + } + + private func openStorePurchases(_ userId: Int64?) -> [OpenStorePurchase] { + guard let storePurchases, storePurchases.userId == userId else { return [] } + return storePurchases.purchases + } + + func price(_ level: BadgeLevel, _ period: BadgePeriod, compact: Bool = true) -> BadgePrice { switch state { case .notLoaded, .loading: return .loading case .loaded, .failed: - if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) } + if let p = products[badgeProductId(level, period)] { return .price(compact ? compactPrice(p) : p.displayPrice) } return .unavailable } } @@ -119,33 +186,158 @@ final class BadgeStore: ObservableObject { } } - func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome { + // A one-time purchase has its core record before the store charges, and every store outcome reaches it. + func purchase(_ level: BadgeLevel, _ period: BadgePeriod) async throws -> BadgePurchaseOutcome { let productId = badgeProductId(level, period) guard let product = await MainActor.run(body: { products[productId] }) else { throw BadgeStoreError.productUnavailable(productId: productId) } - switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { - case let .success(verification): - let transaction: Transaction - let signatureVerified: Bool - switch verification { - case let .verified(t): - transaction = t - signatureVerified = true - case let .unverified(t, _): - transaction = t - signatureVerified = false + // a subscription is never sent to core, so nothing would ever finish it later + guard badgeOneTimeProductIds.contains(productId) else { + let outcome = try await storePurchase(product, newBadgeInvoiceId()) + if case let .purchased(receipt) = outcome { await receipt.transaction.finish() } + return outcome + } + guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { + throw BadgeStoreError.noActiveProfile + } + let invoice = try await apiCreateBadgeInvoice(userId) + guard let invoiceId = UUID(uuidString: invoice) else { throw BadgeStoreError.invalidInvoiceId(invoice) } + await MainActor.run { buying = true } + await loadBadgeStateAsync(userId) + do { + let outcome = try await storePurchase(product, invoiceId) + if case let .purchased(receipt) = outcome, receipt.signatureVerified { + try await handOver(receipt) } - // nothing is delivered in this build, so the transaction is finished right away; once the - // service issues credentials it must only be finished after the credential is stored - await transaction.finish() - return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified)) - case .pending: return .pending + await MainActor.run { buying = false } + return outcome + } catch let error { + await MainActor.run { buying = false } + throw error + } + } + + private func storePurchase(_ product: Product, _ invoiceId: UUID) async throws -> BadgePurchaseOutcome { + switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { + case let .success(verification): return .purchased(storeReceipt(verification)) + case .pending: + let pending = coreInvoiceId(invoiceId) + await MainActor.run { _ = waitingForApproval.insert(pending) } + return .pending(invoiceId: pending) case .userCancelled: return .cancelled @unknown default: throw BadgeStoreError.unknownPurchaseResult } } + // Core holds the receipt and credits it; the transaction stays unfinished until core answers it credited + // or refused, as an unfinished transaction is what the store re-delivers if anything is lost on the way. + private func handOver(_ receipt: BadgeStoreReceipt) async throws { + guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return } + // a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer + do { + switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws)) { + case let .held(user, badgeState, storePurchases): + await MainActor.run { + // the answer is the owner's, which may be another profile and a hidden one + if active(user) { + BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) + setStorePurchases(user.userId, storePurchases) + } + } + case let .credited(user, badgeState): + await MainActor.run { + if active(user) { + BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) + ChatModel.shared.updateUser(user) + if badgeState?.shown == true { UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) } + } + } + await receipt.transaction.finish() + } + } catch let error where badgeReceiptRefused(error) { + logger.error("BadgeStore.handOver: \(responseError(error))") + await receipt.transaction.finish() + } + } + + func storePurchaseCredited(_ user: UserRef, _ invoiceId: String) async { + await MainActor.run { + // an open badges screen is the notice only for the active profile, whose badge it shows + if !active(user) || !badgesViewShown { showBadgeAddedAlert(user) } + } + await storePurchaseResolved(user, invoiceId) + } + + func storePurchaseRefused(_ user: UserRef, _ invoiceId: String, _ refusal: BadgeIssueFailure) async { + await MainActor.run { + if active(user) && openStorePurchases(user.userId).contains(where: { $0.invoiceId == invoiceId }) { + showAlert(refusal.purchaseAlertTitle, message: refusal.purchaseText) + } + } + await storePurchaseResolved(user, invoiceId) + } + + private func storePurchaseResolved(_ user: UserRef, _ invoiceId: String) async { + // the row goes before the store is read, or the screen shows it open while the store answers; + // the transaction is finished whoever owns it, as only the app can, while the cached row is the active profile's + await MainActor.run { + if active(user), let open = storePurchases, open.userId == user.userId { + storePurchases = (open.userId, open.purchases.filter { $0.invoiceId != invoiceId }) + } + } + for await verification in Transaction.unfinished { + let receipt = storeReceipt(verification) + if receipt.echoedInvoiceId == invoiceId { + await receipt.transaction.finish() + return + } + } + } + + // at launch, on return to the foreground and on a profile switch, never on a timer + func presentUnfinished() async { + await listenForTransactions() + for await verification in Transaction.unfinished { + await reconcile(verification) + } + await loadCurrentBadgeState() + await MainActor.run { reconciledOnce = true } + } + + // transactions the store settles outside a purchase call, such as an approved Ask to Buy + @MainActor + private func listenForTransactions() { + if transactionUpdates == nil { + transactionUpdates = Task.detached { + for await verification in Transaction.updates { + await BadgeStore.shared.reconcile(verification) + } + } + } + } + + // an unverified one-time transaction is never sent, and left unfinished rather than lost + private func reconcile(_ verification: VerificationResult) async { + let receipt = storeReceipt(verification) + if !badgeOneTimeProductIds.contains(receipt.productId) { + await receipt.transaction.finish() + } else if receipt.signatureVerified { + // the receipt stays unfinished, so the next sweep hands it over again + do { try await handOver(receipt) } catch let error { + logger.error("BadgeStore.reconcile: \(responseError(error))") + await MainActor.run { + // showAlert presents on the top view controller, which the launch sweep can precede; + // an alert with nowhere to go must not spend the one this run gets + if !sweepFailureShown, getTopViewController() != nil { + sweepFailureShown = true + showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(error, purchase: true)) + } + } + } + } + } + @MainActor private func startLoading() -> Bool { switch state { @@ -169,15 +361,25 @@ private func compactPrice(_ product: Product) -> String { : product.displayPrice } -private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt { - var environment: String? = nil - if #available(iOS 16.0, *) { environment = t.environment.rawValue } +private func storeReceipt(_ verification: VerificationResult) -> BadgeStoreReceipt { + let (t, signatureVerified) = switch verification { + case let .verified(t): (t, true) + case let .unverified(t, _): (t, false) + } return BadgeStoreReceipt( - jws: jws, + jws: verification.jwsRepresentation, productId: t.productID, - transactionId: t.id, invoiceId: t.appAccountToken, - environment: environment, - signatureVerified: signatureVerified + signatureVerified: signatureVerified, + transaction: t ) } + +// the codes core refuses a receipt with for good, after which the store may stop re-delivering it; +// a sweep that hands over a receipt refused while the app was not running learns it only from this answer +private func badgeReceiptRefused(_ error: Error) -> Bool { + if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError { + return code == .receiptInvalid || code == .receiptUsed || code == .testPurchase + } + return false +} diff --git a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift index 45d94c3ed6..10b68be473 100644 --- a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift +++ b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift @@ -9,15 +9,9 @@ import SwiftUI import SimpleXChat -struct BadgeUserPreview: View { +struct BadgeUserPreview: View { @EnvironmentObject var chatModel: ChatModel let level: BadgeLevel - let trailing: () -> Trailing - - init(level: BadgeLevel, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) { - self.level = level - self.trailing = trailing - } var body: some View { let user = chatModel.currentUser @@ -29,12 +23,9 @@ struct BadgeUserPreview: View { ) return VStack(spacing: 12) { ProfileImage(imageStr: user?.image, size: 128) - HStack(alignment: .center, spacing: 6) { - NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle) - .lineLimit(1) - .minimumScaleFactor(0.75) - trailing() - } + NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle) + .lineLimit(1) + .minimumScaleFactor(0.75) } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift new file mode 100644 index 0000000000..2c0dd851ed --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift @@ -0,0 +1,131 @@ +// +// BadgesCheckOrderView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesCheckOrderView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var store = BadgeStore.shared + let level: BadgeLevel + let period: BadgePeriod + @State private var purchasing = false + // presented from this view, not AlertManager: its host is behind the sheet these views open in + @State private var alert: SomeAlert? + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text("Check your order") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 0) { + orderRow("Your badge", Text(level.title)) + orderRow("Duration", Text(period.label)) + Divider() + .padding(.top, 8) + .padding(.bottom, 6) + orderRow("Total", period.priceText(store.price(level, period, compact: false))) + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.top, 20) + + Spacer(minLength: 20) + + VStack(spacing: 10) { + payButton() + .padding(.vertical, 10) + BadgeBillingFooter(period: period) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } + .alert(item: $alert) { $0.alert } + } + + private func orderRow(_ title: LocalizedStringKey, _ value: Text) -> some View { + HStack { + Text(title) + .foregroundColor(theme.colors.secondary) + Spacer() + value + .fontWeight(.medium) + } + .font(.body) + .padding(.vertical, 8) + } + + private func payButton() -> some View { + let price = store.price(level, period, compact: false) + let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId) + return Button { + purchase() + } label: { + period.payText(price) + } + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) + } + + private func purchase() { + purchasing = true + Task { + do { + if case let .purchased(receipt) = try await store.purchase(level, period), !receipt.signatureVerified { + await MainActor.run { + alert = SomeAlert( + alert: mkAlert( + title: "Cannot verify this purchase", + message: "The store returned a transaction that Apple has not signed. SimpleX cannot verify this purchase with the App Store." + ), + id: "badgePurchaseUnverified" + ) + } + } + await MainActor.run { purchasing = false } + } catch let error { + logger.error("BadgesCheckOrderView.purchase: \(String(describing: error))") + await MainActor.run { + purchasing = false + alert = SomeAlert( + alert: Alert( + title: Text("Purchase error"), + message: Text(verbatim: redeemErrorText(error, purchase: true)) + ), + id: "badgePurchaseError" + ) + } + } + } + } +} + +struct BadgesCheckOrderView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesCheckOrderView(level: .supporter, period: .oneMonth) + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift b/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift similarity index 67% rename from apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift rename to apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift index d47bcaa0a7..ba7b5b8613 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesChooseBadgeView.swift @@ -1,5 +1,5 @@ // -// BadgesYourLevelView.swift +// BadgesChooseBadgeView.swift // SimpleX (iOS) // // Created by spaced4ndy on 28.07.2026. @@ -23,17 +23,24 @@ enum BadgeLevel: String, CaseIterable, Identifiable { } } - var filesDescription: LocalizedStringKey { + var fileSize: LocalizedStringKey { switch self { - case .supporter: "Send 2GB files" - case .legend: "Send 5GB files" + case .supporter: "Files up to 2 GB" + case .legend: "Files up to 5 GB" } } - var tagline: LocalizedStringKey { + var fileStorage: LocalizedStringKey { switch self { - case .supporter: "Optional profile badge\nand 2GB files" - case .legend: "Optional profile badge\nand 5GB files" + case .supporter: "Stored for 7 days" + case .legend: "Stored for 21 days" + } + } + + var summary: LocalizedStringKey { + switch self { + case .supporter: "Supporter: 2 GB files available for 7 days." + case .legend: "Legend: 5 GB files available for 21 days." } } @@ -45,32 +52,33 @@ enum BadgeLevel: String, CaseIterable, Identifiable { } } -struct BadgesYourLevelView: View { +struct BadgesChooseBadgeView: View { @EnvironmentObject var theme: AppTheme @ObservedObject private var store = BadgeStore.shared @State private var selectedLevel: BadgeLevel = .supporter @State private var continueActive = false - @State private var howItWorksActive = false var body: some View { GeometryReader { g in ScrollView { VStack(alignment: .center, spacing: 16) { - Text("Your level") + Text("Choose your badge") .font(.largeTitle) .bold() .foregroundColor(theme.colors.primary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - BadgeUserPreview(level: selectedLevel) { - Image(systemName: "chevron.down") - .font(.body) - .foregroundColor(theme.colors.primary) - } - .padding(.top, 4) + Text("Larger files that stay available longer.") + .font(.body) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 20) + BadgeUserPreview(level: selectedLevel) + .padding(.top, 4) + + Spacer(minLength: 12) // fixedSize + maxHeight on the cards so both match the taller one when a // store price wraps in one of them @@ -80,13 +88,20 @@ struct BadgesYourLevelView: View { } .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 20) + Spacer(minLength: 12) VStack(spacing: 10) { continueButton() .padding(.vertical, 10) - howItWorksButton() - .frame(height: 22) + // redeeming a code is here only when Support SimpleX offers the browser instead + Group { + if badgeBrowserAllowed { + RedeemCodeButton() + } else { + Color.clear + } + } + .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -110,17 +125,20 @@ struct BadgesYourLevelView: View { Image(badgeImageName(level.badgeType)) .resizable() .scaledToFit() - .frame(width: 60, height: 60) - .padding(.top, 20) + .frame(width: 44, height: 44) + .padding(.top, 16) Text(level.title) .font(.title3) .fontWeight(.bold) - Text(level.filesDescription) - .font(.subheadline) - .foregroundColor(theme.colors.secondary) BadgePeriod.monthly.priceText(store.price(level, .monthly)) .font(.body) - .padding(.bottom, 20) + VStack(spacing: 2) { + Text(level.fileSize) + Text(level.fileStorage) + } + .font(.subheadline) + .foregroundColor(theme.colors.secondary) + .padding(.bottom, 16) } .multilineTextAlignment(.center) .padding(.horizontal, 12) @@ -145,30 +163,7 @@ struct BadgesYourLevelView: View { .buttonStyle(OnboardingButtonStyle(isDisabled: false)) NavigationLink(isActive: $continueActive) { - BadgesPayView(level: selectedLevel) - .modifier(ThemedBackground()) - } label: { - EmptyView() - } - .frame(width: 1, height: 1) - .hidden() - } - } - - private func howItWorksButton() -> some View { - ZStack { - Button { - howItWorksActive = true - } label: { - HStack(spacing: 4) { - Image(systemName: "info.circle") - Text("How badges protect your privacy").fontWeight(.medium) - } - .font(.body) - } - - NavigationLink(isActive: $howItWorksActive) { - BadgesHowItWorksView() + BadgesHowLongView(level: selectedLevel) .modifier(ThemedBackground()) } label: { EmptyView() @@ -179,10 +174,10 @@ struct BadgesYourLevelView: View { } } -struct BadgesYourLevelView_Previews: PreviewProvider { +struct BadgesChooseBadgeView_Previews: PreviewProvider { static var previews: some View { NavigationView { - BadgesYourLevelView() + BadgesChooseBadgeView() } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift new file mode 100644 index 0000000000..2377a79a3b --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift @@ -0,0 +1,222 @@ +// +// BadgesHowLongView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum BadgePeriod: String, CaseIterable, Identifiable { + case oneMonth + case monthly + case annual + + var id: String { rawValue } + + var icon: String { + switch self { + case .oneMonth: "calendar" + case .monthly: "arrow.clockwise" + case .annual: "arrow.clockwise" + } + } + + var label: LocalizedStringKey { + switch self { + case .oneMonth: "1 month" + case .monthly: "Monthly" + case .annual: "Annual" + } + } + + var months: Int { + switch self { + case .oneMonth: 1 + case .monthly: 1 + case .annual: 12 + } + } + + func priceText(_ price: BadgePrice) -> Text { + switch price { + case .loading: return Text(verbatim: "…") + case .unavailable: return Text(verbatim: "—") + case let .price(p): + switch self { + case .oneMonth: return Text(verbatim: p) + case .monthly: return Text("\(p)/month") + case .annual: return Text("\(p)/year") + } + } + } + + func payText(_ price: BadgePrice) -> Text { + switch price { + case .loading: return Text("Loading…") + case .unavailable: return Text("Not available") + case let .price(p): + switch self { + case .oneMonth: return Text("Pay \(p)") + case .monthly: return Text("Pay \(p)/month") + case .annual: return Text("Pay \(p)/year") + } + } + } +} + +struct BadgesHowLongView: View { + @EnvironmentObject var theme: AppTheme + @ObservedObject private var store = BadgeStore.shared + let level: BadgeLevel + @State private var selectedPeriod: BadgePeriod = badgePeriodsForSale.contains(.monthly) ? .monthly : .oneMonth + @State private var continueActive = false + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text("How long?") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text(level.summary) + .font(.body) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + BadgeUserPreview(level: level) + .padding(.top, 4) + + Spacer(minLength: 20) + + // fixedSize + maxHeight on the cards so they all match the tallest one - + // only Annual carries a savings line, and prices wrap at large fonts + HStack(alignment: .top, spacing: 12) { + ForEach(BadgePeriod.allCases) { periodCard($0) } + } + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 20) + + VStack(spacing: 10) { + continueButton() + .padding(.vertical, 10) + BadgeBillingFooter(period: selectedPeriod) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } + } + + private func periodCard(_ period: BadgePeriod) -> some View { + let isSelected = period == selectedPeriod + let forSale = badgePeriodsForSale.contains(period) + return Button { + selectedPeriod = period + } label: { + VStack(spacing: 12) { + Image(systemName: period.icon) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + .opacity(forSale ? 1 : 0.4) + Text(period.label) + .font(.subheadline) + period.priceText(store.price(level, period)) + .font(.headline) + if let percent = savingsPercent(period) { + Text("\(percent)% off") + .font(.footnote) + .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + } + } + .foregroundColor(forSale ? nil : theme.colors.secondary) + .multilineTextAlignment(.center) + .padding(.vertical, 25) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + } + .buttonStyle(.plain) + .disabled(!forSale) + } + + private func savingsPercent(_ period: BadgePeriod) -> Int? { + period == .annual ? store.annualSavings(level) : nil + } + + private func continueButton() -> some View { + ZStack { + Button { + continueActive = true + } label: { + Text("Continue") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $continueActive) { + BadgesCheckOrderView(level: level, period: selectedPeriod) + .modifier(ThemedBackground(grouped: true)) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } +} + +struct BadgeBillingFooter: View { + @EnvironmentObject var theme: AppTheme + let period: BadgePeriod + + var body: some View { + Text(billingFooter) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(height: 22) + } + + private var billingFooter: LocalizedStringKey { + // TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld); + // a top-up must count from the end of the existing balance + let endDate = Calendar.current.date(byAdding: .month, value: period.months, to: Date()) ?? Date() + let date = DateFormatter.localizedString(from: endDate, dateStyle: .long, timeStyle: .none) + switch period { + case .monthly, .annual: return "Renews on \(date). Cancel anytime." + case .oneMonth: return "Ends on \(date)." + } + } +} + +struct BadgesHowLongView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesHowLongView(level: .supporter) + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesPayView.swift b/apps/ios/Shared/Views/Badges/BadgesPayView.swift deleted file mode 100644 index 707bdb3059..0000000000 --- a/apps/ios/Shared/Views/Badges/BadgesPayView.swift +++ /dev/null @@ -1,269 +0,0 @@ -// -// BadgesPayView.swift -// SimpleX (iOS) -// -// Created by spaced4ndy on 28.07.2026. -// Copyright © 2026 SimpleX Chat. All rights reserved. -// - -import SwiftUI -import SimpleXChat - -// TODO [badges]: replace with types produced by the badge purchase API when it lands. -enum BadgePeriod: String, CaseIterable, Identifiable { - case oneMonth - case monthly - case annual - - var id: String { rawValue } - - var icon: String { - switch self { - case .oneMonth: "calendar" - case .monthly: "arrow.clockwise" - case .annual: "arrow.clockwise" - } - } - - var label: LocalizedStringKey { - switch self { - case .oneMonth: "1 month" - case .monthly: "Monthly" - case .annual: "Annual" - } - } - - func priceText(_ price: BadgePrice) -> Text { - switch price { - case .loading: return Text(verbatim: "…") - case .unavailable: return Text(verbatim: "—") - case let .price(p): - switch self { - case .oneMonth: return Text(verbatim: p) - case .monthly: return Text("\(p)/month") - case .annual: return Text("\(p)/year") - } - } - } - - func payText(_ price: BadgePrice) -> Text { - switch price { - case .loading: return Text("Loading…") - case .unavailable: return Text("Not available") - case let .price(p): - switch self { - case .oneMonth: return Text("Pay \(p)") - case .monthly: return Text("Pay \(p)/month") - case .annual: return Text("Pay \(p)/year") - } - } - } -} - -struct BadgesPayView: View { - @EnvironmentObject var theme: AppTheme - @ObservedObject private var store = BadgeStore.shared - let level: BadgeLevel - @State private var selectedPeriod: BadgePeriod = .monthly - @State private var purchasing = false - // presented from this view, not AlertManager: its host is behind the sheet these views open in - @State private var alert: SomeAlert? - - var body: some View { - GeometryReader { g in - ScrollView { - VStack(alignment: .center, spacing: 16) { - Text(level.title) - .font(.largeTitle) - .bold() - .foregroundColor(theme.colors.primary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - - BadgeUserPreview(level: level) - .padding(.top, 4) - - Text(level.tagline) - .font(.body) - .foregroundColor(theme.colors.onBackground) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 4) - - Spacer(minLength: 20) - - // fixedSize + maxHeight on the cards so all three match the tallest one - - // only Annual carries a savings line, and prices wrap at large fonts - HStack(alignment: .top, spacing: 12) { - periodCard(.oneMonth) - periodCard(.monthly) - periodCard(.annual) - } - .fixedSize(horizontal: false, vertical: true) - - Spacer(minLength: 20) - - VStack(spacing: 10) { - payButton() - .padding(.vertical, 10) - Text(billingFooter) - .font(.footnote) - .foregroundColor(theme.colors.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .frame(height: 22) - } - .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) - } - .padding(.horizontal, 25) - .padding(.top, 0) - .padding(.bottom, 20) - .frame(minHeight: g.size.height) - } - } - .frame(maxHeight: .infinity) - .navigationBarTitleDisplayMode(.inline) - .task { await store.load() } - .alert(item: $alert) { $0.alert } - } - - private func periodCard(_ period: BadgePeriod) -> some View { - let isSelected = period == selectedPeriod - return Button { - selectedPeriod = period - } label: { - VStack(spacing: 12) { - Image(systemName: period.icon) - .resizable() - .scaledToFit() - .frame(width: 32, height: 32) - .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) - Text(period.label) - .font(.title3) - .fontWeight(.bold) - period.priceText(store.price(level, period)) - .font(.body) - if let percent = savingsPercent(period) { - Text("Save \(percent)%") - .font(.footnote) - .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) - } - } - .multilineTextAlignment(.center) - .padding(.vertical, 25) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2) - ) - } - .buttonStyle(.plain) - } - - private func savingsPercent(_ period: BadgePeriod) -> Int? { - period == .annual ? store.annualSavings(level) : nil - } - - private func payButton() -> some View { - let price = store.price(level, selectedPeriod) - let disabled = !price.canPurchase || purchasing - return Button { - purchase() - } label: { - selectedPeriod.payText(price) - } - .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) - .disabled(disabled) - } - - private func purchase() { - let period = selectedPeriod - let invoiceId = newBadgeInvoiceId() - purchasing = true - Task { - do { - let outcome = try await store.purchase(level, period, invoiceId: invoiceId) - await MainActor.run { - purchasing = false - switch outcome { - case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId) - case .pending: - alert = SomeAlert( - alert: mkAlert( - title: "Purchase pending", - message: "The purchase is awaiting approval. This build does not deliver purchases approved later." - ), - id: "badgePurchasePending" - ) - case .cancelled: break - } - } - } catch let error { - logger.error("BadgesPayView.purchase: \(String(describing: error))") - await MainActor.run { - purchasing = false - alert = SomeAlert( - alert: Alert( - title: Text("Purchase error"), - message: Text(verbatim: String(describing: error)) - ), - id: "badgePurchaseError" - ) - } - } - } - } - - // TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. - private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) { - let returnedInvoice: String - if let returned = receipt.invoiceId { - returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)" - } else { - returnedInvoice = "none" - } - var lines = [ - "Product: \(receipt.productId)", - "Invoice: \(invoiceId.uuidString)", - "Invoice returned by Apple: \(returnedInvoice)", - "Transaction: \(receipt.transactionId)" - ] - if let environment = receipt.environment { lines.append("Environment: \(environment)") } - lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")") - lines.append("Token: \(receipt.jws.count) bytes") - let summary = lines.joined(separator: "\n") - // logged as well as shown: the alert races StoreKit's own sheets, the log always lands - logger.debug("badge purchase succeeded\n\(summary)") - alert = SomeAlert( - alert: Alert( - title: Text("Purchase successful"), - message: Text(verbatim: summary), - primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws }, - secondaryButton: .cancel(Text("Ok")) - ), - id: "badgePurchased" - ) - } - - private var billingFooter: LocalizedStringKey { - // TODO [badges] source the actual date from the purchase state machine when wired. - var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22 - let stubDate = Calendar.current.date(from: comps) ?? Date() - let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none) - switch selectedPeriod { - case .monthly, .annual: return "Renews on \(date). Cancel anytime." - case .oneMonth: return "Ends on \(date)." - } - } -} - -struct BadgesPayView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - BadgesPayView(level: .supporter) - } - } -} diff --git a/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift new file mode 100644 index 0000000000..702304acd4 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesPurchaseStateView.swift @@ -0,0 +1,100 @@ +// +// BadgesPurchaseStateView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 29.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesPurchaseStateView: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.dismiss) private var dismiss + let title: LocalizedStringKey + var message: LocalizedStringKey? = nil + var failure: BadgeIssueFailure? = nil + var showsAsSheet: Bool = false + + var body: some View { + GeometryReader { g in + ScrollView { + content + .padding(.horizontal, 25) + .padding(.top, showsAsSheet ? 48 : 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private var content: some View { + VStack(alignment: .center, spacing: 16) { + Text(title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + if let message { + Text(message) + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + if let failure { + VStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text(failure.purchaseText) + .font(.body) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer() + + ProgressView().scaleEffect(2) + + Spacer() + + VStack(spacing: 10) { + Button { + dismiss() + } label: { + Text("Dismiss") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + .padding() + } + .padding(.vertical, 10) + Color.clear + .frame(height: 22) + } + } + } +} + +extension BadgePurchaseState { + var title: LocalizedStringKey { + switch self { + case .issuing: "Issuing your badge" + case .waitingForApproval: "Waiting for approval" + } + } + + var message: LocalizedStringKey { + switch self { + case .issuing: "Your payment is complete. The badge will be added to this profile." + case .waitingForApproval: "Nothing has been charged." + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift index 6ea0c58edb..0cf0ec0673 100644 --- a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -33,10 +33,52 @@ private func formatBadgeCodeInput(_ s: String) -> String { return groups.joined(separator: "-") } +enum BadgeRedeemOutcome { + case redeemed + case refused(message: String) + case cancelled +} + +// sets the badge before returning, so a caller that dismisses on .redeemed lands on Your Badge +// instead of showing the switch from Support. .cancelled when the user cancels the retry alert. +func redeemBadgeCode(_ user: User, _ code: String) async -> BadgeRedeemOutcome { + do { + guard let redeemed = try await apiRedeemBadgeCode(user.userId, code) else { return .cancelled } + return await MainActor.run { () -> BadgeRedeemOutcome in + BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState) + ChatModel.shared.updateUser(redeemed.user) + if let badgeState = redeemed.badgeState, !badgeState.shown { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + return .refused(message: redeemed.newBadge + ? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") + : NSLocalizedString("This code has already been used.", comment: "alert message") + ) + } + UserDefaults.standard.set(true, forKey: DEFAULT_SUPPORTER_BANNER_SHOWN) + return .redeemed + } + } catch let error { + logger.error("apiRedeemBadgeCode: \(responseError(error))") + return .refused(message: redeemErrorText(error, purchase: false)) + } +} + +func showCannotRedeemAlert(_ message: String) { + showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) +} + +// a hidden profile is named only while it is the active one, which is unlocked and on screen +func showBadgeAddedAlert(_ user: any UserLike) { + guard let owner = ChatModel.shared.getUser(user.userId), active(owner) || !owner.hidden else { return } + showAlert( + NSLocalizedString("Badge added", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("The badge was added to the profile %@.", comment: "alert message"), owner.displayName) + ) +} + struct BadgesRedeemCodeView: View { @EnvironmentObject var theme: AppTheme @EnvironmentObject var chatModel: ChatModel - @AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false @Environment(\.dismiss) var dismiss: DismissAction @State private var code = "" @State private var canonicalCode: String? = nil @@ -184,39 +226,217 @@ struct BadgesRedeemCodeView: View { guard let sending = canonicalCode, let user = chatModel.currentUser else { return } submitting = true Task { - do { - guard let redeemed = try await apiRedeemBadgeCode(user.userId, sending) else { - await MainActor.run { submitting = false } - return - } - await MainActor.run { - submitting = false - // set before dismissing: BadgesView then switches Support to Your Badge while this screen - // still covers it, so the pop lands on Your Badge instead of showing the switch - BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState) - chatModel.updateUser(redeemed.user) - if let badgeState = redeemed.badgeState, !badgeState.shown { - // a replay adds no purchase; a fresh code's badge can be retired on arrival - let message = redeemed.newBadge - ? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") - : NSLocalizedString("This code has already been used.", comment: "alert message") - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) - } else { - supporterBannerShown = true - dismiss() - } - } - } catch let error { - logger.error("apiRedeemBadgeCode: \(responseError(error))") - await MainActor.run { - submitting = false - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: redeemErrorText(error)) + let outcome = await redeemBadgeCode(user, sending) + await MainActor.run { + submitting = false + switch outcome { + case .redeemed: dismiss() + case let .refused(message): showCannotRedeemAlert(message) + case .cancelled: break } } } } } +private weak var badgeLinkSheet: UIViewController? +// the open screen's own state, not a copy of its step, so the two cannot disagree +private weak var badgeLinkState: BadgeLinkState? + +func isBadgeLinkIssuing() -> Bool { + badgeLinkSheet?.presentingViewController != nil && badgeLinkState?.step == .issuing +} + +func openBadgeLink(_ codeText: String) { + guard let code = parseBadgeCode(codeText) else { + return showCannotRedeemAlert(NSLocalizedString("This code is not valid.", comment: "alert message")) + } + let state = BadgeLinkState() + badgeLinkSheet = showAppSheet { + NavigationView { + BadgesRedeemLinkView(code: code, state: state) + .modifier(ThemedBackground()) + } + } + badgeLinkState = state +} + +enum BadgeLinkStep { + case confirming + case issuing + case redeemed + case viewingBadge +} + +final class BadgeLinkState: ObservableObject { + @Published var step = BadgeLinkStep.confirming +} + +// Any web page can send a badge link, and a profile holds one badge at a time, +// so this screen asks before redeeming, names the profile, and offers nothing but the redemption. +// It is a code redemption with the code hidden, so it inherits the redeem screen's behaviour: an +// interrupted request is not resumed, and the code stays on the page that issued the link. +struct BadgesRedeemLinkView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var badgeModel = BadgeModel.shared + let code: String + @ObservedObject var state: BadgeLinkState + + var body: some View { + switch state.step { + case .confirming: if profileHasBadge { badgeHeld() } else { confirming() } + case .issuing: beingIssued() + case .redeemed, .viewingBadge: BadgesView(showsAsSheet: true) + } + } + + // read for the profile the screen names, when it renders, so the two cannot disagree; + // core refuses a code while the profile shows a badge + private var profileHasBadge: Bool { + badgeModel.userId == chatModel.currentUser?.userId && badgeModel.badgeState?.shown == true + } + + // the screen cannot tell a repeated link for the badge it shows from a code for another badge, + // so it asserts neither + private func badgeHeld() -> some View { + linkStep( + "Profile already has a badge", + primary: ("View your badge", { state.step = .viewingBadge }), + textButton: ("Cancel", { closeIfShowing() }) + ) { + linkText(NSLocalizedString("A badge can be added to another profile, or here once this badge ends.", comment: "badge link, profile has a badge")) + linkText(NSLocalizedString("Its code is on the page you bought it on, under Show code.", comment: "badge link, profile has a badge")) + } + } + + private func confirming() -> some View { + linkStep( + "Add badge to your profile?", + primary: ("Add badge", { redeemFromLink() }), + textButton: ("Cancel", { closeIfShowing() }) + ) { + linkText(String.localizedStringWithFormat(NSLocalizedString("The badge will be added to the profile %@.", comment: "badge link confirmation"), chatModel.currentUser?.displayName ?? "")) + } + } + + // leaving does not cancel: the badge is still added after the screen closes + private func beingIssued() -> some View { + linkStep("Issuing your badge", textButton: ("Dismiss", { closeIfShowing() })) { + linkText(NSLocalizedString("The badge will be added to this profile.", comment: "badge link, being issued")) + Spacer() + ProgressView().scaleEffect(2) + } + } + + // with no primary, the text button takes its place, so a button does not move between steps + private func linkStep( + _ title: LocalizedStringKey, + primary: (label: LocalizedStringKey, action: () -> Void)? = nil, + textButton: (label: LocalizedStringKey, action: () -> Void), + @ViewBuilder content: () -> Content + ) -> some View { + let textButtonLabel = Text(textButton.label) + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + return VStack(alignment: .center, spacing: 16) { + Text(title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + content() + + Spacer() + + VStack(spacing: 10) { + if let primary { + Button { + primary.action() + } label: { + Text(primary.label) + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .padding(.vertical, 10) + + Button { + textButton.action() + } label: { + textButtonLabel + } + .frame(height: 22) + } else { + Button { + textButton.action() + } label: { + // the inset OnboardingButtonStyle gives a primary + textButtonLabel.padding() + } + .padding(.vertical, 10) + Color.clear + .frame(height: 22) + } + } + } + .padding(.horizontal, 25) + .padding(.top, 48) + .padding(.bottom, 20) + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func linkText(_ text: String) -> some View { + Text(text) + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + private func redeemFromLink() { + // a second tap before the screen changes must not send the code again + guard state.step == .confirming else { return } + guard let user = chatModel.currentUser else { return closeIfShowing() } + state.step = .issuing + Task { + let outcome = await redeemBadgeCode(user, code) + await MainActor.run { + switch outcome { + case .redeemed: + if !isShowing { showBadgeAddedAlert(user) } + // a covered screen is not left on the spinner; a closed one is not written to + if isOpen { state.step = .redeemed } + case let .refused(message): closeIfShowing { showCannotRedeemAlert(message) } + case .cancelled: closeIfShowing() + } + } + } + } + + private var isOpen: Bool { + badgeLinkState === state && badgeLinkSheet?.presentingViewController != nil + } + + // an alert over this screen still counts: dismissing from the presenter dismisses it too + private var isShowing: Bool { + guard isOpen, let sheet = badgeLinkSheet else { return false } + return sheet.presentedViewController == nil || sheet.presentedViewController is UIAlertController + } + + // An outcome can arrive after this screen was closed or covered: it must not close another screen, + // and a covered one goes back to asking, so it is never left locked on the spinner. + private func closeIfShowing(then: @escaping () -> Void = {}) { + if isShowing, let presenter = badgeLinkSheet?.presentingViewController { + presenter.dismiss(animated: true, completion: then) + } else { + if isOpen { state.step = .confirming } + then() + } + } +} + struct BadgesRedeemCodeView_Previews: PreviewProvider { static var previews: some View { NavigationView { diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 55d05f2615..97e91bfb69 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -11,13 +11,14 @@ import SimpleXChat struct BadgesSupportSimplexView: View { @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var store = BadgeStore.shared // set true when presented as a sheet root (from the chat-list banner) — that path doesn't // reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top var showsAsSheet: Bool = false @State private var whyBuiltActive = false @State private var howItWorksActive = false @State private var chooseLevelActive = false - @State private var redeemCodeActive = false var body: some View { GeometryReader { g in @@ -34,8 +35,6 @@ struct BadgesSupportSimplexView: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - // TODO [badges] restore whyBuiltButton() when in-app purchase lands: the level screen - // returns to the flow and howItWorksButton() moves there, leaving this one alone here. howItWorksButton() Spacer(minLength: 0) @@ -46,11 +45,27 @@ struct BadgesSupportSimplexView: View { Spacer(minLength: 0) + // In-app purchase is not released: the badge is chosen on the site, and the code + // redeemed here. Revert this commit to restore the block below. + // VStack(spacing: 10) { + // chooseLevelButton() + // .padding(.vertical, 10) + // Group { + // if badgeBrowserAllowed { + // buyInBrowserButton() + // } else { + // RedeemCodeButton() + // } + // } + // .frame(height: 22) + // } VStack(spacing: 10) { - redeemCodeButton() + chooseBadgeOnSiteButton() .padding(.vertical, 10) - getCodeButton() - .frame(height: 22) + Group { + RedeemCodeButton() + } + .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -65,18 +80,19 @@ struct BadgesSupportSimplexView: View { .navigationBarTitleDisplayMode(.inline) } - // the in-app purchase path, kept compiling and uncalled until payments return after the MVP private func chooseLevelButton() -> some View { - ZStack { + let disabled = !store.canBuy(chatModel.currentUser?.userId) + return ZStack { Button { chooseLevelActive = true } label: { - Text("Choose your level") + Text("Choose your badge") } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) NavigationLink(isActive: $chooseLevelActive) { - BadgesYourLevelView() + BadgesChooseBadgeView() .modifier(ThemedBackground()) } label: { EmptyView() @@ -125,14 +141,42 @@ struct BadgesSupportSimplexView: View { } } - private func redeemCodeButton() -> some View { + // In-app purchase is not released: delete with the commented block above to restore it. + private func chooseBadgeOnSiteButton() -> some View { + Button { + UIApplication.shared.open(URL(string: badgePageUrl)!) + } label: { + Text("Choose your badge") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + } + + private func buyInBrowserButton() -> some View { + Button { + UIApplication.shared.open(URL(string: badgePageUrl)!) + } label: { + Text("Buy in browser") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + } +} + +struct RedeemCodeButton: View { + @EnvironmentObject var theme: AppTheme + @State private var redeemCodeActive = false + + var body: some View { ZStack { Button { redeemCodeActive = true } label: { Text("Redeem badge code") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) NavigationLink(isActive: $redeemCodeActive) { BadgesRedeemCodeView() @@ -144,17 +188,6 @@ struct BadgesSupportSimplexView: View { .hidden() } } - - private func getCodeButton() -> some View { - Button { - openExternalLink(URL(string: "https://simplex.chat/badges/")!) - } label: { - Text("Get your code") - .font(.body) - .fontWeight(.medium) - .foregroundColor(theme.colors.primary) - } - } } struct PhoneSupporterHero: View { diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift index df12094a6b..c6a9749dd4 100644 --- a/apps/ios/Shared/Views/Badges/BadgesView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct BadgesView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject private var badgeModel = BadgeModel.shared + @ObservedObject private var store = BadgeStore.shared var showsAsSheet: Bool = false private var shownBadge: BadgeState? { @@ -25,15 +26,28 @@ struct BadgesView: View { if let badgeState = shownBadge { BadgesYourBadgeView(badgeState: badgeState, showsAsSheet: showsAsSheet) .transition(.opacity) + } else if let purchaseState = store.purchaseState(chatModel.currentUser?.userId) { + // holds the purchase screens' slot, so a consumable cannot be bought twice + BadgesPurchaseStateView(title: purchaseState.title, message: purchaseState.message, failure: store.creditError(chatModel.currentUser?.userId), showsAsSheet: showsAsSheet) + .transition(.opacity) + } else if store.checkingPurchases { + BadgesPurchaseStateView(title: "Checking your purchases", showsAsSheet: showsAsSheet) + .transition(.opacity) } else { BadgesSupportSimplexView(showsAsSheet: showsAsSheet) .transition(.opacity) } } .animation(.default, value: shownBadge != nil) + .animation(.default, value: store.purchaseState(chatModel.currentUser?.userId)) + .animation(.default, value: store.checkingPurchases) + .onAppear { badgesViewShown = true } + .onDisappear { badgesViewShown = false } } } +private(set) var badgesViewShown = false + var supportSimpleXAlertAction: UIAlertAction { UIAlertAction(title: NSLocalizedString("Support SimpleX", comment: "alert button"), style: .default) { _ in openBadgesView() diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 754bcb2715..a2e65dcf71 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -161,10 +161,10 @@ struct ActiveCallView: View { CallSoundsPlayer.shared.stop() } } - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): Task { do { - try await apiSendCallExtraInfo(call.contact, iceCandidates) + try await apiSendCallExtraInfo(call.contact, session, iceCandidates) } catch { logger.error("apiSendCallExtraInfo \(responseError(error))") } @@ -172,15 +172,24 @@ struct ActiveCallView: View { case let .connection(state): if let callStatus = WebRTCCallStatus.init(rawValue: state.connectionState), case .connected = callStatus { - call.direction == .outgoing - ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) - : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) + // connectedAt is kept on reconnection, so that call duration is not reset + if call.connectedAt == nil { + call.direction == .outgoing + ? CallController.shared.reportOutgoingCall(call: call, connectedAt: nil) + : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) + call.connectedAt = .now + } call.callState = .connected - call.connectedAt = .now + CallSoundsPlayer.shared.stop() if !wasConnected { CallSoundsPlayer.shared.vibrate(long: false) wasConnected = true } + } else if state.connectionState == "reconnecting" { + if call.callState != .reconnecting { + CallSoundsPlayer.shared.startConnectingCallSound() + } + call.callState = .reconnecting } if state.connectionState == "closed" { closeCallView(client) @@ -190,17 +199,22 @@ struct ActiveCallView: View { m.activeCall = nil m.activeCallViewIsCollapsed = false } - Task { - do { - try await apiCallStatus(call.contact, state.connectionState) - } catch { - logger.error("apiCallStatus \(responseError(error))") + // "reconnecting" is not a core call status + if state.connectionState != "reconnecting" { + Task { + do { + try await apiCallStatus(call.contact, state.connectionState) + } catch { + logger.error("apiCallStatus \(responseError(error))") + } } } case let .connected(connectionInfo): call.callState = .connected call.connectionInfo = connectionInfo - call.connectedAt = .now + if call.connectedAt == nil { + call.connectedAt = .now + } if !wasConnected { CallSoundsPlayer.shared.vibrate(long: false) wasConnected = true diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index ef9135761c..6f7a0efa38 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -77,6 +77,7 @@ enum CallState { case answerReceived // incoming - webrtc answer received via API case negotiated // outgoing - webrtc offer processed and answer sent, incoming - webrtc answer processed case connected + case reconnecting case ended var text: LocalizedStringKey { @@ -89,6 +90,7 @@ enum CallState { case .answerReceived: return "received confirmation…" case .negotiated: return "connecting…" case .connected: return "connected" + case .reconnecting: return "reconnecting…" case .ended: return "ended" } } @@ -119,7 +121,7 @@ enum WCallCommand: Equatable, Encodable, Decodable { case start(media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) case offer(offer: String, iceCandidates: String, media: CallMediaType, aesKey: String? = nil, iceServers: [RTCIceServer]? = nil, relay: Bool? = nil) case answer(answer: String, iceCandidates: String) - case ice(iceCandidates: String) + case ice(iceCandidates: String, session: String? = nil) case media(source: CallMediaSource, enable: Bool) case end @@ -131,6 +133,7 @@ enum WCallCommand: Equatable, Encodable, Decodable { case offer case answer case iceCandidates + case session case enable case iceServers case relay @@ -174,9 +177,10 @@ enum WCallCommand: Equatable, Encodable, Decodable { try container.encode("answer", forKey: .type) try container.encode(answer, forKey: .answer) try container.encode(iceCandidates, forKey: .iceCandidates) - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): try container.encode("ice", forKey: .type) try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encodeIfPresent(session, forKey: .session) case let .media(source, enable): try container.encode("media", forKey: .type) try container.encode(source, forKey: .media) @@ -213,7 +217,8 @@ enum WCallCommand: Equatable, Encodable, Decodable { self = .answer(answer: answer, iceCandidates: iceCandidates) case "ice": let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) - self = .ice(iceCandidates: iceCandidates) + let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session) + self = .ice(iceCandidates: iceCandidates, session: session) case "media": let source = try container.decode(CallMediaSource.self, forKey: CodingKeys.source) let enable = try container.decode(Bool.self, forKey: CodingKeys.enable) @@ -231,7 +236,7 @@ enum WCallResponse: Equatable, Decodable { case capabilities(capabilities: CallCapabilities) case offer(offer: String, iceCandidates: String, capabilities: CallCapabilities) case answer(answer: String, iceCandidates: String) - case ice(iceCandidates: String) + case ice(iceCandidates: String, session: String? = nil) case connection(state: ConnectionState) case connected(connectionInfo: ConnectionInfo) case peerMedia(source: CallMediaSource, enabled: Bool) @@ -246,6 +251,7 @@ enum WCallResponse: Equatable, Decodable { case offer case answer case iceCandidates + case session case state case connectionInfo case message @@ -290,7 +296,8 @@ enum WCallResponse: Equatable, Decodable { self = .answer(answer: answer, iceCandidates: iceCandidates) case "ice": let iceCandidates = try container.decode(String.self, forKey: CodingKeys.iceCandidates) - self = .ice(iceCandidates: iceCandidates) + let session = try container.decodeIfPresent(String.self, forKey: CodingKeys.session) + self = .ice(iceCandidates: iceCandidates, session: session) case "connection": let state = try container.decode(ConnectionState.self, forKey: CodingKeys.state) self = .connection(state: state) @@ -333,9 +340,10 @@ extension WCallResponse: Encodable { try container.encode("answer", forKey: .type) try container.encode(answer, forKey: .answer) try container.encode(iceCandidates, forKey: .iceCandidates) - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): try container.encode("ice", forKey: .type) try container.encode(iceCandidates, forKey: .iceCandidates) + try container.encodeIfPresent(session, forKey: .session) case let .connection(state): try container.encode("connection", forKey: .type) try container.encode(state, forKey: .state) diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index c7d53d3c69..15d36f7931 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -20,6 +20,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg }() private static let ivTagBytes: Int = 28 private static let enableEncryption: Bool = true + private static let reconnectTimeout: UInt64 = 30_000_000_000 + private static let iceRestartInterval: UInt64 = 10_000_000_000 private var chat_ctrl = getChatCtrl() struct Call { @@ -37,6 +39,9 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg var frameEncryptor: RTCFrameEncryptor? var frameDecryptor: RTCFrameDecryptor? var peerHasOldVersion: Bool + var wasConnected = false + var reconnection: Task? = nil + var iceRestarts: Task? = nil } struct NotConnectedCall { @@ -257,11 +262,15 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg resp = .error(message: "answer: remote description is not set") } } - case let .ice(iceCandidates): + case let .ice(iceCandidates, session): if let pc = pc, let remoteIceCandidates: [RTCIceCandidate] = decodeJSON(decompressFromBase64(input: iceCandidates)) { - addIceCandidates(pc, remoteIceCandidates) - resp = .ok + if let session { + resp = await receiveRestartSession(pc, session, remoteIceCandidates) + } else { + addIceCandidates(pc, remoteIceCandidates) + resp = .ok + } } else { resp = .error(message: "ice: call not started") } @@ -301,6 +310,101 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + private func startReconnecting(_ connection: RTCPeerConnection) async { + activeCall?.reconnection = Task { + _ = try? await Task.sleep(nanoseconds: WebRTCClient.reconnectTimeout) + if Task.isCancelled { return } + await sendConnectionState(connection, "disconnected") + endCall() + } + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + if connection.localDescription?.type == .offer { + activeCall?.iceRestarts = Task { + while !Task.isCancelled { + await restartIce(connection) + _ = try? await Task.sleep(nanoseconds: WebRTCClient.iceRestartInterval) + } + } + } + await sendConnectionState(connection, "reconnecting") + } + + private func stopReconnecting() { + activeCall?.reconnection?.cancel() + activeCall?.reconnection = nil + activeCall?.iceRestarts?.cancel() + activeCall?.iceRestarts = nil + } + + private func restartIce(_ connection: RTCPeerConnection) async { + // the previous restart offer is not answered yet + if connection.signalingState != .stable { return } + let (offer, error) = await connection.offer(iceRestart: true) + if let offer { + await sendRestartSession(offer) + } else { + logger.error("WebRTCClient: ICE restart error: \(error?.localizedDescription ?? "unknown error")") + } + } + + private func receiveRestartSession(_ pc: RTCPeerConnection, _ session: String, _ remoteIceCandidates: [RTCIceCandidate]) async -> WCallResponse { + guard let desc: CustomRTCSessionDescription = decodeJSON(decompressFromBase64(input: session)), + let type = desc.type, let sdp = desc.sdp else { + return .error(message: "ice: invalid session") + } + let remoteDesc = RTCSessionDescription(type: type.toWebRTCSdpType(), sdp: sdp) + if type == .offer && pc.localDescription?.type != .offer { + if (try? await pc.setRemoteDescription(remoteDesc)) == nil { + return .error(message: "ice: remote offer is not set") + } + let (answer, error) = await pc.answer() + guard let answer else { + return .error(message: "ice: answer error: \(error?.localizedDescription ?? "unknown error")") + } + addIceCandidates(pc, remoteIceCandidates) + await sendRestartSession(answer) + } else if type == .answer && pc.signalingState == .haveLocalOffer { + if (try? await pc.setRemoteDescription(remoteDesc)) == nil { + return .error(message: "ice: remote answer is not set") + } + addIceCandidates(pc, remoteIceCandidates) + } else { + return .error(message: "ice: unexpected ICE restart \(type.rawValue)") + } + return .ok + } + + private func sendRestartSession(_ desc: RTCSessionDescription) async { + await sendCallResponse(.init( + corrId: nil, + resp: .ice( + iceCandidates: compressToBase64(input: encodeJSON(await getInitialIceCandidates())), + session: compressToBase64(input: encodeJSON(CustomRTCSessionDescription(type: desc.type.toSdpType(), sdp: desc.sdp))) + ), + command: nil) + ) + waitForMoreIceCandidates() + } + + private func sendConnectionState(_ connection: RTCPeerConnection, _ connectionState: String) async { + guard let iceConnectionState = connection.iceConnectionState.toString(), + let iceGatheringState = connection.iceGatheringState.toString(), + let signalingState = connection.signalingState.toString() + else { + return + } + await sendCallResponse(.init( + corrId: nil, + resp: .connection(state: ConnectionState( + connectionState: connectionState, + iceConnectionState: iceConnectionState, + iceGatheringState: iceGatheringState, + signalingState: signalingState) + ), + command: nil) + ) + } + // Spec: spec/services/calls.md#sendIceCandidates func sendIceCandidates(_ candidates: [RTCIceCandidate]) async { await self.sendCallResponse(.init( @@ -657,6 +761,8 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg (notConnectedCall?.localCameraAndTrack?.0 as? RTCCameraVideoCapturer)?.stopCapture() guard let call = activeCall else { return } logger.debug("WebRTCClient: ending the call") + call.reconnection?.cancel() + call.iceRestarts?.cancel() call.connection.close() call.connection.delegate = nil call.frameEncryptor?.delegate = nil @@ -678,16 +784,18 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg extension WebRTC.RTCPeerConnection { - func mediaConstraints() -> RTCMediaConstraints { - RTCMediaConstraints( - mandatoryConstraints: [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, - kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue], - optionalConstraints: nil) + func mediaConstraints(iceRestart: Bool = false) -> RTCMediaConstraints { + var constraints = [kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, + kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue] + if iceRestart { + constraints[kRTCMediaConstraintsIceRestart] = kRTCMediaConstraintsValueTrue + } + return RTCMediaConstraints(mandatoryConstraints: constraints, optionalConstraints: nil) } - func offer() async -> (RTCSessionDescription?, Error?) { + func offer(iceRestart: Bool = false) async -> (RTCSessionDescription?, Error?) { await withCheckedContinuation { cont in - offer(for: mediaConstraints()) { (sdp, error) in + offer(for: mediaConstraints(iceRestart: iceRestart)) { (sdp, error) in self.processSDP(cont, sdp, error) } } @@ -766,24 +874,20 @@ extension WebRTCClient: RTCPeerConnectionDelegate { func peerConnection(_ connection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { debugPrint("Connection new connection state: \(newState.toString() ?? "" + newState.rawValue.description) \(connection.receivers)") - guard let connectionStateString = newState.toString(), - let iceConnectionStateString = connection.iceConnectionState.toString(), - let iceGatheringStateString = connection.iceGatheringState.toString(), - let signalingStateString = connection.signalingState.toString() - else { - return - } + guard let connectionStateString = newState.toString() else { return } Task { - await sendCallResponse(.init( - corrId: nil, - resp: .connection(state: ConnectionState( - connectionState: connectionStateString, - iceConnectionState: iceConnectionStateString, - iceGatheringState: iceGatheringStateString, - signalingState: signalingStateString) - ), - command: nil) - ) + if activeCall?.wasConnected == true { + switch newState { + case .disconnected, .failed: + if activeCall?.reconnection == nil { + await startReconnecting(connection) + } + return + case .new, .checking: return + default: () + } + } + await sendConnectionState(connection, connectionStateString) switch newState { case .checking: @@ -792,7 +896,10 @@ extension WebRTCClient: RTCPeerConnectionDelegate { } let enableSpeaker: Bool = ChatModel.shared.activeCall?.localMediaSources.hasVideo == true setSpeakerEnabledAndConfigureSession(enableSpeaker) - case .connected: sendConnectedEvent(connection) + case .connected: + stopReconnecting() + activeCall?.wasConnected = true + sendConnectedEvent(connection) case .disconnected, .failed: endCall() default: () } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift index aaaa29929d..1eebc5cf19 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatLinkHeader.swift @@ -50,7 +50,7 @@ struct CIChatLinkHeader: View { HStack(alignment: .top) { ProfileImage( imageStr: chatLink.image, - iconName: chatLink.iconName, + icon: Image(systemName: chatLink.iconName), size: 44, color: Color(uiColor: .tertiaryLabel) ) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 8bb76ca155..5c65a310b5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -26,7 +26,7 @@ struct CIFileView: View { var body: some View { if smallViewSize != nil { fileIndicator() - .simultaneousGesture(TapGesture().onEnded(fileAction)) + .simultaneousGesture(TapGesture().onEnded { fileAction(file) }) } else { // reserve exact space for the overlaid meta (timestamp + all icons), rendered transparently - matches MsgContentView let encrypted: Bool? = if let fileSource = file?.fileSource { fileSource.cryptoArgs != nil } else { nil } @@ -56,7 +56,7 @@ struct CIFileView: View { .padding(.bottom, 6) .padding(.leading, 10) .padding(.trailing, 12) - .simultaneousGesture(TapGesture().onEnded(fileAction)) + .simultaneousGesture(TapGesture().onEnded { fileAction(file) }) .disabled(!itemInteractive) } } @@ -85,7 +85,7 @@ struct CIFileView: View { return false } - private func fileAction() { + private func fileAction(_ file: CIFile?) { logger.debug("CIFileView fileAction") if let file = file { switch (file.fileStatus) { @@ -93,6 +93,7 @@ struct CIFileView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file) { fileAction($0) } Task { logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task") if let user = m.currentUser { @@ -117,7 +118,7 @@ struct CIFileView: View { case .rcvComplete: logger.debug("CIFileView fileAction - in .rcvComplete") if let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case let .rcvError(rcvFileError): logger.debug("CIFileView fileAction - in .rcvError") @@ -128,12 +129,12 @@ struct CIFileView: View { case .sndStored: logger.debug("CIFileView fileAction - in .sndStored") if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case .sndComplete: logger.debug("CIFileView fileAction - in .sndComplete") if let fileSource = getLoadedFileSource(file) { - saveCryptoFile(fileSource) + openFile(file, fileSource) } case let .sndError(sndFileError): logger.debug("CIFileView fileAction - in .sndError") @@ -146,6 +147,14 @@ struct CIFileView: View { } } + private func openFile(_ file: CIFile, _ fileSource: CryptoFile) { + if isMarkdownFile(file) { + showMarkdownFile(file) + } else { + saveCryptoFile(fileSource) + } + } + @ViewBuilder private func fileIndicator() -> some View { if let file = file { switch file.fileStatus { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index ddb58fdfd1..efec44e943 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -39,6 +39,13 @@ struct CIGroupInvitationView: View { Divider().frame(width: frameWidth) + if chat.chatInfo.groupInfo != nil { + Text(chatItem.chatDir.sent ? String.localizedStringWithFormat(NSLocalizedString("For %@ only", comment: ""), groupInvitation.localDisplayName) : NSLocalizedString("For you only", comment: "")) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .overlay(DetermineWidth()) + } + if action { VStack(alignment: .leading, spacing: 2) { groupInvitationText() @@ -108,7 +115,7 @@ struct CIGroupInvitationView: View { return HStack(alignment: .top) { ProfileImage( imageStr: groupInvitation.groupProfile.image, - iconName: "person.2.circle.fill", + icon: Image(systemName: "person.2.circle.fill"), size: 44, color: color ) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index fb6ccb1ea3..469fc5a3fc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -54,6 +54,7 @@ struct CIImageView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file) { _ in showFullScreenImage = true } Task { if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 4eb187bcac..42259361b7 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -52,7 +52,7 @@ struct CILinkView: View { func openBrowserAlert(uri: String) { let (url, err) = sanitizeUri(uri) if let url { - let uriStr = url.uri.absoluteString + let uriStr = displayedUri(url.uri.absoluteString) showAlert( NSLocalizedString("Open link?", comment: "alert title"), message: uriStr.count > 160 ? "\(uriStr.prefix(160))…" : uriStr, @@ -90,9 +90,9 @@ func openBrowserAlert(uri: String) { func showInvalidLinkAlert(_ uri: String, error: String? = nil) { let message = if let error, !error.isEmpty { - error + "\n" + uri + error + "\n" + displayedUri(uri) } else { - uri + displayedUri(uri) } showAlert( NSLocalizedString("Invalid link", comment: "alert title"), @@ -101,6 +101,25 @@ func showInvalidLinkAlert(_ uri: String, error: String? = nil) { ) } +func unicodeMarker(_ s: Unicode.Scalar) -> String { + String(format: "[U+%04X]", s.value) +} + +func displayedUri(_ uri: String) -> String { + let authorityStart = uri.range(of: "://")?.upperBound ?? uri.startIndex + let authority = authorityStart ..< (uri[authorityStart...].firstIndex(where: { "/?#".contains($0) }) ?? uri.endIndex) + var shown = "" + for i in uri.unicodeScalars.indices { + let s = uri.unicodeScalars[i] + if s.properties.generalCategory == .format || (!s.isASCII && authority.contains(i)) { + shown += unicodeMarker(s) + } else { + shown.unicodeScalars.append(s) + } + } + return shown +} + func sanitizeUri(_ s: String) -> (url: (uri: URL, sanitizedUri: URL?)?, error: String?) { let parsed = parseSanitizeUri(s, safe: false) return if let uri = URL(string: s), let uriInfo = parsed?.uriInfo { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 0df17dfe3d..339d119362 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -120,6 +120,25 @@ struct CIVideoView: View { } } + private func playReceived(_ file: CIFile) { + setupPlayer(file) + if urlDecrypted == nil { + decrypt(file: file, completed: playVideo) + } else { + playVideo() + } + } + + private func playVideo() { + if smallView { + showFullScreenPlayer = urlDecrypted != nil + } else if let urlDecrypted { + m.stopPreviousRecPlay = urlDecrypted + videoPlaying = true + player?.play() + } + } + private func setupPlayer(_ file: CIFile?) { let newUrl = getLoadedVideo(file) if newUrl == url { @@ -424,6 +443,7 @@ struct CIVideoView: View { if let prohibited = file.fileProhibited { showProhibitedFileAlert(file, prohibited) } else { + m.openWhenReceived(file, open: playReceived) Task { if let user = m.currentUser { await receiveFile(user, file.fileId, false, false) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 64fa2a4dd3..6c89a5f788 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -303,6 +303,11 @@ struct VoiceMessagePlayer: View { private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { playPauseIcon(icon, theme.colors.primary) .simultaneousGesture(TapGesture().onEnded { + chatModel.openWhenReceived(recordingFile) { file in + if let recordingSource = getLoadedFileSource(file) { + startPlayback(recordingSource) + } + } Task { if let user = chatModel.currentUser { await receiveFile(user: user, fileId: recordingFile.fileId) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index f822970ea4..b6bac78d46 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -334,7 +334,7 @@ struct FramedItemView: View { @inline(__always) private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int, stripLink: String? = nil, prefix: NSAttributedString? = nil) -> some View { - MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, markdownBlocks: qi.markdownBlocks, textStyle: .subheadline, prefix: prefix, stripLink: stripLink) .lineLimit(lines) .padding(.bottom, 6) } @@ -364,13 +364,16 @@ struct FramedItemView: View { chat: chat, text: text, formattedText: ft, + markdownBlocks: text == "" ? nil : ci.markdownBlocks, textStyle: .body, meta: ci.meta, mentions: ci.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, rightToLeft: rtl, prefix: txtPrefix, - stripLink: stripLink + stripLink: stripLink, + blockLayout: true, + cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote ) .environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme))) .multilineTextAlignment(rtl ? .trailing : .leading) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift new file mode 100644 index 0000000000..aa7e827d6b --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkdownFileView.swift @@ -0,0 +1,215 @@ +import SwiftUI +import SimpleXChat +import Ink + +private let MAX_MARKDOWN_VIEW_SIZE: Int64 = 1_048_576 +private let MAX_MARKDOWN_LINE_LENGTH = 15_000 +private let MAX_MARKDOWN_NESTING = 16 +private let MAX_MARKDOWN_LINK_NESTING = 4 +private let MAX_MARKDOWN_TABLE_ROWS = 200 +private let MAX_MARKDOWN_IMAGE_DIMENSION: CGFloat = 4320 +private let MAX_MARKDOWN_IMAGE_PIXELS: CGFloat = 2160 * 4320 + +func isMarkdownFile(_ file: CIFile) -> Bool { + let ext = (file.fileName as NSString).pathExtension.lowercased() + return file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && (ext == "md" || ext == "markdown") +} + +func showMarkdownFile(_ file: CIFile) { + guard let fileSource = getLoadedFileSource(file) else { return } + Task { + do { + let data = try getFileData(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs) + let text = decodedText(data) + let lines = text.components(separatedBy: "\n") + let html = withinMarkdownLimits(lines) + ? MarkdownParser(modifiers: [Modifier(target: .html) { escapedHTML($0.markdown) }, imagesWithinBudget()]).html(from: text) + : nil + let shownLines = lines.flatMap(chunkedLine) + let hasBidiControls = text.unicodeScalars.contains(where: isBidiControl) + await MainActor.run { + showAppSheet { + MarkdownFileView(file: file, fileSource: fileSource, lines: shownLines, html: html, hasBidiControls: hasBidiControls) + } + } + } catch { + await MainActor.run { + showAlert(NSLocalizedString("Error reading file", comment: "alert title"), message: error.localizedDescription) + } + } + } +} + +private func decodedText(_ data: Data) -> String { + var text = String.UnicodeScalarView() + var parser = Unicode.UTF8.ForwardParser() + var bytes = data.makeIterator() + while true { + switch parser.parseScalar(from: &bytes) { + case let .valid(encoded): text.append(replacingControlChar(Unicode.UTF8.decode(encoded))) + case let .error(length): text.append(contentsOf: repeatElement("?" as Unicode.Scalar, count: length)) + case .emptyInput: return String(text).replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") + } + } +} + +private func replacingControlChar(_ s: Unicode.Scalar) -> Unicode.Scalar { + s.value < 0x20 && s != "\t" && s != "\n" && s != "\r" ? "?" : s +} + +private func withinMarkdownLimits(_ lines: [String]) -> Bool { + var tableRows = 0 + var linkNesting = 0 + for line in lines { + tableRows = line.contains("|") ? tableRows + 1 : 0 + if line.count > MAX_MARKDOWN_LINE_LENGTH || nestingLevel(line) > MAX_MARKDOWN_NESTING || tableRows > MAX_MARKDOWN_TABLE_ROWS || !linkNestingWithinLimit(line, &linkNesting) { + return false + } + } + return true +} + +private func linkNestingWithinLimit(_ line: String, _ nesting: inout Int) -> Bool { + if line.isEmpty { nesting = 0 } + for c in line { + if c == "[" { + nesting += 1 + if nesting > MAX_MARKDOWN_LINK_NESTING { return false } + } else if c == "]" && nesting > 0 { + nesting -= 1 + } + } + return true +} + +private func nestingLevel(_ line: String) -> Int { + let content = line.drop(while: { $0 == " " || $0 == "\t" || $0 == ">" }) + let prefix = line[.." }.count + let listItem = content.first.map { "-*+".contains($0) || $0.isNumber } ?? false + return quotes + (listItem ? (prefix.count - quotes) / 2 : 0) +} + +private func chunkedLine(_ line: String) -> [String] { + let chars = Array(line) + return stride(from: 0, to: max(chars.count, 1), by: MAX_MARKDOWN_LINE_LENGTH).map { + String(chars[$0 ..< min($0 + MAX_MARKDOWN_LINE_LENGTH, chars.count)]) + } +} + +private func escapedHTML(_ s: Substring) -> String { + s.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") +} + +private func imagesWithinBudget() -> Modifier { + var pixels = MAX_MARKDOWN_IMAGE_PIXELS + return Modifier(target: .images) { image in + let src = image.html.dropFirst("" + } +} + +private func isBidiControl(_ s: Unicode.Scalar) -> Bool { + [0x061C, 0x200E, 0x200F].contains(s.value) || (0x202A...0x202E).contains(s.value) || (0x2066...0x2069).contains(s.value) +} + +private func withInvisibleCharMarkers(_ line: String) -> Text { + var text = Text(verbatim: "") + var plain = "" + for s in line.unicodeScalars { + if s.properties.generalCategory == .format { + text = text + Text(verbatim: plain) + Text(verbatim: unicodeMarker(s)).foregroundColor(.orange) + plain = "" + } else { + plain.unicodeScalars.append(s) + } + } + return text + Text(verbatim: plain) +} + +private func openMarkdownFileLink(_ url: URL) { + let uri = url.absoluteString + let parsed = parseSimpleXMarkdown(uri) + let format = parsed?.count == 1 ? parsed?.first?.format : nil + switch format { + case let .simplexLink(_, _, simplexUri, _)?: + if let simplexUrl = URL(string: simplexUri) { + ChatModel.shared.appOpenUrl = simplexUrl + } else { + showInvalidLinkAlert(uri) + } + case .uri? where uri.hasPrefix("https://") || uri.hasPrefix("http://"): + openBrowserAlert(uri: uri) + default: + showInvalidLinkAlert(uri) + } +} + +struct MarkdownFileView: View { + @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW) private var textView = false + let file: CIFile + let fileSource: CryptoFile + let lines: [String] + let html: String? + let hasBidiControls: Bool + + var body: some View { + NavigationView { + VStack(spacing: 0) { + if hasBidiControls { + Text("This file has text direction characters. The text can be shown in a different order than it is written.") + .foregroundColor(.orange) + .padding() + } + if let html, !textView { + ConditionsWebView(html: html, openLink: openMarkdownFileLink) + .padding(.horizontal) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(lines.indices, id: \.self) { i in + withInvisibleCharMarkers(lines[i]) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding() + } + } + } + .modifier(ThemedBackground()) + .navigationTitle(file.fileName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if html != nil { + Button { + textView.toggle() + } label: { + Image(systemName: textView ? "doc.richtext" : "chevron.left.forwardslash.chevron.right") + } + } + Button { + saveCryptoFile(fileSource) + } label: { + Image(systemName: "square.and.arrow.up") + } + } + } + } + .navigationViewStyle(.stack) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index c9d8858fa1..10bd784b7e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -32,6 +32,7 @@ struct MsgContentView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? = nil + var markdownBlocks: [MarkdownBlock]? = nil var textStyle: UIFont.TextStyle var sender: String? = nil var meta: CIMeta? = nil @@ -40,6 +41,8 @@ struct MsgContentView: View { var rightToLeft = false var prefix: NSAttributedString? = nil var stripLink: String? = nil + var blockLayout = false + var cardColor: Color? = nil @State private var showSecrets: Set = [] @State private var typingIdx = 0 @State private var timer: Timer? @@ -51,7 +54,7 @@ struct MsgContentView: View { @AppStorage(DEFAULT_PRIVACY_SHOW_SIGNATURE) private var showSignature = true var body: some View { - let v = msgContentView() + let v = contentView() if meta?.isLive == true { v.onAppear { let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) @@ -95,11 +98,67 @@ struct MsgContentView: View { typingIdx = 0 } + @ViewBuilder + private func contentView() -> some View { + let fts = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } + if blockLayout, let fts, !fts.isEmpty, let blocks = markdownBlocks { + MarkdownBlocksView( + formattedText: fts, + blocks: blocks, + textStyle: textStyle, + cardColor: cardColor ?? theme.appColors.receivedQuote, + rightToLeft: rightToLeft, + blockText: { index, range, font, secretStart, last in blockTextView(fts, blocks, index, range, font, secretStart, last) }, + metaLine: { metaLineView() } + ) + } else { + msgContentView() + } + } + + private func blockTextView(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ index: Int, _ range: Range, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> some View { + let r = messageText( + text, + fts, + markdownBlocks: blocks, + textStyle: textStyle, + sender: index == 0 ? sender : nil, + mentions: mentions, + userMemberId: userMemberId, + showSecrets: showSecrets, + commands: chat.chatInfo.useCommands && chat.chatInfo.sndReady, + backgroundColor: containerBackground, + prefix: index == 0 ? prefix : nil, + range: range, + baseFont: font, + secretStart: secretStart + ) + let s = r.string + let t: Text + if last, let mt = meta { + if mt.isLive { + s.append(typingIndicator(mt.recent)) + } + t = Text(AttributedString(s)) + reserveSpaceForMeta(mt) + } else { + t = Text(AttributedString(s)) + } + return msgTextResultView(r, t, showSecrets: $showSecrets, sendCommand: { cmd in sendCommandMsg(chat, cmd) }) + } + + @ViewBuilder + private func metaLineView() -> some View { + if let mt = meta { + (mt.isLive ? Text(AttributedString(typingIndicator(mt.recent))) : Text(verbatim: "")) + reserveSpaceForMeta(mt, newLine: false) + } + } + @inline(__always) private func msgContentView() -> some View { let r = messageText( text, formattedText, + markdownBlocks: markdownBlocks, textStyle: textStyle, sender: sender, mentions: mentions, @@ -131,8 +190,8 @@ struct MsgContentView: View { } @inline(__always) - private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) + private func reserveSpaceForMeta(_ mt: CIMeta, newLine: Bool = true) -> Text { + (rightToLeft && newLine ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp, showSignature: showSignature) } } @@ -317,8 +376,11 @@ private struct FullProfileDescriptionView: View { .listRowBackground(Color.clear) Section { - let r = markdownText(description, showSecrets: showSecrets, backgroundColor: theme.colors.background) - msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + let parsed = parseSimpleXMarkdownText(description) + MarkdownView( + text: description, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks, + backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets + ) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) } @@ -338,9 +400,11 @@ func markdownText( showSecrets: Set? = nil, backgroundColor: Color ) -> MsgTextResult { - messageText( + let parsed = parseSimpleXMarkdownText(s) + return messageText( s, - parseSimpleXMarkdown(s), + parsed?.formattedText, + markdownBlocks: parsed?.markdownBlocks, textStyle: textStyle, sender: sender, preview: preview, @@ -396,6 +460,7 @@ func markdownProfileDescription( func messageText( _ text: String, _ formattedText: [FormattedText]?, + markdownBlocks: [MarkdownBlock]? = nil, textStyle: UIFont.TextStyle = .body, sender: String?, preview: Bool = false, @@ -405,21 +470,24 @@ func messageText( commands: Bool = false, backgroundColor: UIColor, prefix: NSAttributedString? = nil, - stripLink: String? = nil + stripLink: String? = nil, + range: Range? = nil, + baseFont: UIFont? = nil, + secretStart: Int = 0 ) -> MsgTextResult { let text = if let stripLink { stripTextLink(text, stripLink) } else { text } let formattedText = if let stripLink { stripFormattedTextLink(formattedText, stripLink) } else { formattedText } let res = NSMutableAttributedString() - let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) - let font = UIFont.preferredFont(forTextStyle: textStyle) + let descr = baseFont?.fontDescriptor ?? UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) + let font = baseFont ?? UIFont.preferredFont(forTextStyle: textStyle) let plain: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: UIColor.label ] let secretColor = backgroundColor.withAlphaComponent(1) - var link: [NSAttributedString.Key: Any]? var hasSecrets = false var handleTaps = false + var secretIdx: Int = secretStart if let sender { if preview { @@ -437,19 +505,57 @@ func messageText( } if let fts = formattedText, fts.count > 0 { + if let range { + let start = min(max(range.lowerBound, 0), fts.count) + appendSegments(fts[start.. (descr: UIFontDescriptor, font: UIFont) { + if !heading { return (descr, font) } + let d = descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]) + return (d, UIFont(descriptor: d, size: d.pointSize)) + } + + func appendSegments(_ fts: ArraySlice, _ heading: Bool) { + let (descr, font) = segmentFonts(heading) + let plain: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.label + ] + var link: [NSAttributedString.Key: Any]? var bold: UIFont? var italic: UIFont? var snippet: UIFont? var small: UIFont? var mention: UIFont? - var secretIdx: Int = 0 for ft in fts { var t = ft.text var attrs = plain switch (ft.format) { - case .bold: - bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) - attrs[.font] = bold + case let .bold(singleStar): + if singleStar && markdownBlocks != nil { + italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) + attrs[.font] = italic + } else { + bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize) + attrs[.font] = bold + } case .italic: italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) attrs[.font] = italic @@ -490,12 +596,13 @@ func messageText( attrs[webLinkAttrKey] = true handleTaps = true } + t = displayedUri(t) case let .hyperLink(text, uri): attrs = linkAttrs() if let text { t = text } if !preview { attrs[linkAttrKey] = uri - attrs[webLinkAttrKey] = true + if !ft.isEmailLink { attrs[webLinkAttrKey] = true } handleTaps = true } case let .simplexLink(text, linkType, simplexUri, smpHosts): @@ -562,24 +669,319 @@ func messageText( attrs[modalAttrKey] = Format.modal(modalName: modalName, text: text) handleTaps = true } + case .escape: t = "" + case let .markup(token): + if range != nil { t = token.drawnText } else if markdownBlocks != nil { t = token.previewText } case .unknown: () case .none: () } res.append(NSAttributedString(string: t, attributes: attrs)) } - } else { - res.append(NSMutableAttributedString(string: text, attributes: plain)) - } - - return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps) - func linkAttrs() -> [NSAttributedString.Key: Any] { - link = link ?? [ - .font: font, - .foregroundColor: uiLinkColor, - .underlineStyle: NSUnderlineStyle.single.rawValue - ] - return link! + func linkAttrs() -> [NSAttributedString.Key: Any] { + link = link ?? [ + .font: font, + .foregroundColor: uiLinkColor, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + return link! + } + } +} + +let swipeExclusionSpace = "swipeExclusion" + +private let maxGridCells = 1000 + +struct SwipeExclusionFrames: PreferenceKey { + static var defaultValue: [CGRect] = [] + + static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { + value += nextValue() + } +} + +struct MarkdownView: View { + var text: String + var formattedText: [FormattedText]? + var markdownBlocks: [MarkdownBlock]? + var sender: String? = nil + var mentions: [String: CIMention]? = nil + var userMemberId: String? = nil + var backgroundColor: Color + var cardColor: Color + @Binding var showSecrets: Set + + var body: some View { + if let fts = formattedText, !fts.isEmpty, let blocks = markdownBlocks { + MarkdownBlocksView( + formattedText: fts, + blocks: blocks, + textStyle: .body, + cardColor: cardColor, + rightToLeft: isRightToLeft(text), + blockText: { index, range, font, secretStart, _ in + let r = messageText( + text, fts, markdownBlocks: blocks, sender: index == 0 ? sender : nil, mentions: mentions, userMemberId: userMemberId, + showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor), range: range, baseFont: font, secretStart: secretStart + ) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + }, + metaLine: { EmptyView() } + ) + } else { + let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) + msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + } + } +} + +private struct BlocksLayout { + let font: UIFont + let starts: [Int] + let secrets: [Int] + let lastIsText: Bool + + init(_ fts: [FormattedText], _ blocks: [MarkdownBlock], _ textStyle: UIFont.TextStyle) { + font = UIFont.preferredFont(forTextStyle: textStyle) + starts = blocks.reduce(into: [0]) { $0.append($0[$0.count - 1] + $1.textRanges.count) } + secrets = fts.reduce(into: [0]) { $0.append($0[$0.count - 1] + ($1.isSecret ? 1 : 0)) } + lastIsText = switch blocks.last { + case .paragraph, .heading, .quote, .list: true + default: false + } + } +} + +struct MarkdownBlocksView: View { + @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness + var formattedText: [FormattedText] + var blocks: [MarkdownBlock] + var textStyle: UIFont.TextStyle + var cardColor: Color + var rightToLeft: Bool + @ViewBuilder var blockText: (_ index: Int, _ range: Range, _ font: UIFont, _ secretStart: Int, _ last: Bool) -> BlockText + @ViewBuilder var metaLine: () -> MetaLine + + var body: some View { + let l = BlocksLayout(formattedText, blocks, textStyle) + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(blocks.enumerated()), id: \.offset) { k, block in + if k > 0 && paragraphBreak(blocks[k - 1].endIndex, block.startIndex) { + Color.clear.frame(height: l.font.lineHeight / 2) + } + blockView(block, l.starts[k], l) + } + if !l.lastIsText { + metaLine() + } + } + .multilineTextAlignment(.leading) + .environment(\.layoutDirection, rightToLeft ? .rightToLeft : .leftToRight) + } + + private func text(_ index: Int, _ r: SegmentRange, _ font: UIFont, _ l: BlocksLayout) -> BlockText { + blockText(index, r.from.. some View { + switch block { + case let .paragraph(content): + text(start, content, l.font, l) + case let .heading(level, content): + text(start, content, headingFont(l.font, level), l) + case let .quote(content): + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 2).fill(Color(uiColor: .label).opacity(0.16)).frame(width: 4) + text(start, content, l.font, l).opacity(0.72) + } + .fixedSize(horizontal: false, vertical: true) + case let .list(items): + ForEach(Array(listEntries(items, 0).enumerated()), id: \.offset) { j, e in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(e.entry.number ?? "•").font(.system(size: l.font.pointSize)) + text(start + j, e.entry.content, l.font, l) + } + .padding(.leading, 20 * CGFloat(e.level)) + } + case let .codeBlock(_, content): + CodeBlockCard(color: cardColor, radius: 12 * roundness, copyText: codeText(content)) { + text(start, content, monospacedFont(l.font), l).fixedSize(horizontal: true, vertical: false) + } + case let .table(columns, header, rows): + tableView(columns, [header] + rows, start, l) + case .rule: + Rectangle().fill(Color(uiColor: .secondaryLabel).opacity(0.35)).frame(height: 1).padding(.vertical, 6) + } + } + + @ViewBuilder private func tableView(_ columns: [TableAlign], _ tableRows: [[SegmentRange]], _ start: Int, _ l: BlocksLayout) -> some View { + let rowStarts = tableRows.reduce(into: [start]) { $0.append($0[$0.count - 1] + $1.count) } + let columnCount = max(columns.count, tableRows.map(\.count).max() ?? 0) + let lineColor = Color(uiColor: .secondaryLabel).opacity(0.35) + let radius = 12 * roundness + if #available(iOS 16, *) { + if columnCount * tableRows.count <= maxGridCells { + HScroll { + Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { + ForEach(Array(tableRows.enumerated()), id: \.offset) { r, row in + GridRow { + ForEach(0.. some View { + let source = tableSource(tableRows) + return CodeBlockCard(color: cardColor, radius: radius, copyText: source) { + Text(verbatim: source).font(.system(size: l.font.pointSize * 0.875, design: .monospaced)).fixedSize(horizontal: true, vertical: false) + } + } + + private func paragraphBreak(_ from: Int, _ to: Int) -> Bool { + let start = min(max(from, 0), formattedText.count) + return formattedText[start.. String { + let start = min(max(r.from, 0), formattedText.count) + return formattedText[start.. String { + let start = min(max((tableRows.first?.first?.from ?? 1) - 1, 0), formattedText.count) + let end = min((tableRows.last?.last?.to ?? 0) + 1, formattedText.count) + return formattedText[start.. UIFont { + let scale: CGFloat = switch level { + case 1: 1.5 + case 2: 1.3 + case 3: 1.15 + default: 1 + } + return UIFont(descriptor: boldFont(font).fontDescriptor, size: font.pointSize * scale) +} + +private func boldFont(_ font: UIFont) -> UIFont { + UIFont(descriptor: font.fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: font.pointSize) +} + +private func monospacedFont(_ font: UIFont) -> UIFont { + UIFont.monospacedSystemFont(ofSize: font.pointSize * 0.875, weight: .regular) +} + +private func cellAlignment(_ align: TableAlign) -> Alignment { + switch align { + case .right: .trailing + case .center: .center + default: .leading + } +} + +private struct CodeBlockCard: View { + @EnvironmentObject var theme: AppTheme + var color: Color + var radius: CGFloat + var copyText: String + @ViewBuilder var content: () -> Content + @State private var showCopy = false + + var body: some View { + HScroll { + content() + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + .overlay(alignment: .topTrailing) { + if showCopy { + Button { + UIPasteboard.general.string = copyText + showCopy = false + } label: { + Image(systemName: "doc.on.doc") + .foregroundColor(theme.colors.primary) + .frame(width: 28, height: 28) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: radius * 2 / 3)) + } + .padding(4) + } + } + .background(color) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .onTapGesture { showCopy.toggle() } + } +} + +private struct HScroll: View { + @ViewBuilder var content: () -> Content + + var body: some View { + if #available(iOS 16, *) { + ViewThatFits(in: .horizontal) { + content() + scrollView() + } + } else { + scrollView() + } + } + + private func scrollView() -> some View { + ScrollView(.horizontal, showsIndicators: false) { + content() + } + .background { + GeometryReader { g in + Color.clear.preference(key: SwipeExclusionFrames.self, value: [g.frame(in: .named(swipeExclusionSpace))]) + } + } } } @@ -607,7 +1009,9 @@ func stripTextLink(_ text: String, _ link: String) -> String { func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [FormattedText]? { guard var ft, ft.last?.text == link else { return ft } ft.removeLast() - if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { + if let i = ft.indices.last, case .markup = ft[i].format { + ft.removeLast() + } else if let i = ft.indices.last, ft[i].format == nil, ft[i].text.hasSuffix("\n") { ft[i].text = String(ft[i].text.dropLast()) if ft[i].text.isEmpty { ft.removeLast() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index ffcf525300..3fff0e34d1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -267,7 +267,7 @@ struct ChatItemInfoView: View { private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { let backgroundColor = chatItemFrameColor(ci, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: backgroundColor) + textBubble(itemVersion.msgContent.text, itemVersion.formattedText, itemVersion.markdownBlocks, nil, backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(backgroundColor) @@ -295,9 +295,12 @@ struct ChatItemInfoView: View { .frame(maxWidth: maxWidth, alignment: .leading) } - @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: Color) -> some View { + @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ markdownBlocks: [MarkdownBlock]?, _ sender: String? = nil, backgroundColor: Color) -> some View { if text != "" { - TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor) + TextBubble( + text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, + backgroundColor: backgroundColor, cardColor: ci.chatDir.sent ? theme.appColors.sentQuote : theme.appColors.receivedQuote + ) } else { Text("no text") .italic() @@ -309,15 +312,19 @@ struct ChatItemInfoView: View { @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? + var markdownBlocks: [MarkdownBlock]? var sender: String? = nil var mentions: [String: CIMention]? var userMemberId: String? var backgroundColor: Color + var cardColor: Color @State private var showSecrets: Set = [] var body: some View { - let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: UIColor(backgroundColor)) - return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + MarkdownView( + text: text, formattedText: formattedText, markdownBlocks: markdownBlocks, sender: sender, mentions: mentions, userMemberId: userMemberId, + backgroundColor: backgroundColor, cardColor: cardColor, showSecrets: $showSecrets + ) } } @@ -342,7 +349,7 @@ struct ChatItemInfoView: View { private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { let backgroundColor = quotedMsgFrameColor(qi, theme) return VStack(alignment: .leading, spacing: 4) { - textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: backgroundColor) + textBubble(qi.text, qi.formattedText, qi.markdownBlocks, qi.getSender(nil), backgroundColor: backgroundColor) .padding(.horizontal, 12) .padding(.vertical, 6) .background(quotedMsgFrameColor(qi, theme)) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index fbaf4f08c3..aabcca5f0e 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1735,6 +1735,7 @@ struct ChatView: View { @State private var markReadTask: Task? = nil @State private var actionSheet: SomeActionSheet? = nil @State private var swipeOffset: CGFloat = 0 + @State private var swipeExclusionFrames: [CGRect] = [] var revealed: Bool { revealedItems.contains(chatItem.id) } @@ -1968,7 +1969,7 @@ struct ChatView: View { .padding(.trailing, 12) } HStack(alignment: .top, spacing: 10) { - ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background) + ProfileImage(imageStr: groupInfo.image, icon: Image(systemName: groupInfo.chatIconName), size: memberImageSize, backgroundColor: theme.colors.background) .simultaneousGesture(TapGesture().onEnded { showChatInfoSheet = true }) @@ -2052,7 +2053,7 @@ struct ChatView: View { } HStack(alignment: .top, spacing: 10) { if ci.meta.showGroupAsSender { - ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background) + ProfileImage(imageStr: groupInfo.image, icon: Image(systemName: groupInfo.chatIconName), size: memberImageSize, backgroundColor: theme.colors.background) .simultaneousGesture(TapGesture().onEnded { showChatInfoSheet = true }) @@ -2156,12 +2157,14 @@ struct ChatView: View { .padding(.bottom, 4) } } + .coordinateSpace(name: swipeExclusionSpace) + .onPreferenceChange(SwipeExclusionFrames.self) { swipeExclusionFrames = $0 } .offset(x: swipeOffset) .contentShape(Rectangle()) .simultaneousGesture( - DragGesture(minimumDistance: 10) + DragGesture(minimumDistance: 10, coordinateSpace: .named(swipeExclusionSpace)) .onChanged { value in - guard canReply else { return } + guard canReply, !swipeExclusionFrames.contains(where: { $0.contains(value.startLocation) }) else { return } let x = value.translation.width if x < 0 { swipeOffset = max(x * 0.63, -56) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift index 650ea8a87f..78f29c3b60 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeChatLinkView.swift @@ -11,7 +11,7 @@ struct ComposeChatLinkView: View { HStack(alignment: .center, spacing: 8) { ProfileImage( imageStr: chatLink.image, - iconName: chatLink.iconName, + icon: Image(systemName: chatLink.iconName), size: 44 ) .padding(.leading, 12) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index a1570ba4a1..fbac41cf40 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -767,6 +767,7 @@ struct ComposeView: View { let connectedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connStatus == .ready && $0.wrapped.activeConn?.connFailedErr == nil }.count let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count + guard total > 0 || chatModel.membersLoaded else { return nil } let noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total return (hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 1f328b2061..13614692cb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -71,7 +71,7 @@ struct ContextItemView: View { } private func contextMsgPreview(_ contextItem: ChatItem) -> some View { - let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) + let r = messageText(contextItem.text, contextItem.formattedText, markdownBlocks: contextItem.markdownBlocks, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background), stripLink: contextItem.content.msgContent?.chatLinkStr) let t = attachment() + Text(AttributedString(r.string)) return t.if(r.hasSecrets, transform: hiddenSecretsView) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 126fcc57b3..19478753af 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -37,7 +37,7 @@ struct GroupProfileView: View { var body: some View { List { - EditProfileImage(profileImage: $groupProfile.image, iconName: groupInfo.chatIconName, showChooseSource: $showChooseSource) + EditProfileImage(profileImage: $groupProfile.image, icon: Image(systemName: groupInfo.chatIconName), showChooseSource: $showChooseSource) .if(!focusDisplayName) { $0.padding(.top) } Section { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index f58f2c213d..1bef869a66 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -59,8 +59,11 @@ struct GroupWelcomeView: View { } private func textPreview() -> some View { - let r = markdownText(welcomeText, showSecrets: showSecrets, backgroundColor: theme.colors.background) - return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) + let parsed = parseSimpleXMarkdownText(welcomeText) + return MarkdownView( + text: welcomeText, formattedText: parsed?.formattedText, markdownBlocks: parsed?.markdownBlocks, + backgroundColor: theme.colors.background, cardColor: theme.appColors.receivedQuote, showSecrets: $showSecrets + ) .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 5ee991a466..7f50f09a31 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -299,7 +299,7 @@ struct ChatPreviewView: View { func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { let (itemText, itemFormattedText) = chatItemPreviewText(cItem) - let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) + let r = messageText(itemText, itemFormattedText, markdownBlocks: itemFormattedText == nil ? nil : cItem.markdownBlocks, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) return (Text(AttributedString(r.string)), r.hasSecrets) func chatItemPreviewText(_ ci: ChatItem) -> (String, [FormattedText]?) { @@ -444,7 +444,7 @@ struct ChatPreviewView: View { smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) { ProfileImage( imageStr: chatLink.image, - iconName: chatLink.iconName, + icon: Image(systemName: chatLink.iconName), size: dynamicMediaSize, color: Color(uiColor: .tertiaryLabel) ) diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index dbc25e536f..a52fdfd189 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -153,6 +153,9 @@ struct DatabaseEncryptionView: View { try apiSaveAppSettings(settings: AppSettings.current.prepareForExport()) } try await apiStorageEncryption(currentKey: currentKey, newKey: newKey) + if currentKey != newKey { + shouldDeleteDatabaseBackupsDefault.set(true) + } encryptionStartedDefault.set(false) initialRandomDBPassphraseGroupDefault.set(false) if migration { diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index f7f253a617..4c4df931cb 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -118,6 +118,9 @@ struct DatabaseErrorView: View { case .errorKeychain: titleText("Keychain error") errorView(Text("Cannot access keychain to save database password")) + case .errorKeyGeneration: + titleText("Database error") + errorView(Text("Cannot generate random database passphrase")) case .invalidConfirmation: // this can only happen if incorrect parameter is passed titleText("Invalid migration confirmation") @@ -217,6 +220,8 @@ struct DatabaseErrorView: View { ) case .errorKeychain: am.showAlertMsg(title: "Keychain error") + case .errorKeyGeneration: + am.showAlertMsg(title: "Database error", message: "Cannot generate random database passphrase") case let .errorSQL(_, error): am.showAlert(Alert( title: Text("Database error"), diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 051ed9f037..047a87cf71 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -453,6 +453,10 @@ struct DatabaseView: View { ChatReceiver.shared.start() chatLastStartGroupDefault.set(Date.now) AppChatState.shared.set(.active) + if shouldDeleteDatabaseBackupsDefault.get() { + deleteDatabaseBackups() + shouldDeleteDatabaseBackupsDefault.set(false) + } } catch let error { runChat.wrappedValue = false showAlert(NSLocalizedString("Error starting chat", comment: ""), message: responseError(error)) diff --git a/apps/ios/Shared/Views/Helpers/AppSheet.swift b/apps/ios/Shared/Views/Helpers/AppSheet.swift index 51ae63f07e..d505cdead7 100644 --- a/apps/ios/Shared/Views/Helpers/AppSheet.swift +++ b/apps/ios/Shared/Views/Helpers/AppSheet.swift @@ -31,14 +31,18 @@ private struct PrivacySensitive: ViewModifier { // Presented from the top view controller instead of a .sheet on a parent view, so an alert button or // a view that is itself in a sheet can open it. -func showAppSheet(@ViewBuilder content: () -> Content) { +@discardableResult +func showAppSheet(@ViewBuilder content: () -> Content) -> UIViewController? { if let topController = getTopViewController() { let v = content() .modifier(PrivacySensitive()) .environmentObject(ChatModel.shared) .environmentObject(AppTheme.shared) - topController.present(UIHostingController(rootView: v), animated: true) + let sheet = UIHostingController(rootView: v) + topController.present(sheet, animated: true) + return sheet } + return nil } extension View { diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift index 40d62e009b..a0f21aef9b 100644 --- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -19,7 +19,7 @@ struct ChatInfoImage: View { let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color } return ProfileImage( imageStr: chat.chatInfo.image, - iconName: chatIconName(chat.chatInfo), + icon: chatIcon(chat.chatInfo), size: size, color: iconColor ) diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift index 9c2916880c..05b4a06967 100644 --- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift +++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift @@ -12,7 +12,7 @@ import SimpleXChat struct ProfileImage: View { @EnvironmentObject var theme: AppTheme var imageStr: String? = nil - var iconName: String = "person.crop.circle.fill" + var icon: Image = Image(systemName: "person.crop.circle.fill") var size: CGFloat var color = Color(uiColor: .tertiarySystemGroupedBackground) var backgroundColor: Color? = nil @@ -24,7 +24,7 @@ struct ProfileImage: View { clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred) } else { let c = color.asAnotherColorFromSecondaryVariant(theme) - Image(systemName: iconName) + icon .resizable() .foregroundColor(c) .scaledToFit() diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 56e437e5c8..ebe74b2a50 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -98,7 +98,7 @@ func openExternalLink(_ url: URL) { } else { showAlert( title: NSLocalizedString("Open external link?", comment: "alert title"), - message: s, + message: displayedUri(s), buttonTitle: NSLocalizedString("Open", comment: "alert button"), buttonAction: { UIApplication.shared.open(url) }, cancelButton: true diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 2ff376701c..c262d1165a 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -709,6 +709,8 @@ private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: alert.wrappedValue = .wrongPassphrase() case .errorKeychain: alert.wrappedValue = .keychainError() + case .errorKeyGeneration: + alert.wrappedValue = .databaseError(message: NSLocalizedString("Cannot generate random database passphrase", comment: "alert message")) case let .errorSQL(_, error): alert.wrappedValue = .databaseError(message: error) case let .unknown(error): diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index cb3832b727..4181f6e73f 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -539,7 +539,7 @@ struct MigrateToDevice: View { Task { do { if !hasChatCtrl() { - chatInitControllerRemovingDatabases() + try chatInitControllerRemovingDatabases() } else if ChatModel.shared.chatRunning == true { // cannot delete storage if chat is running try await stopChatAsync() @@ -735,6 +735,8 @@ private func showErrorOnMigrationIfNeeded(_ status: DBMigrationResult, _ alert: alert.wrappedValue = .wrongPassphrase() case .errorKeychain: alert.wrappedValue = .keychainError() + case .errorKeyGeneration: + alert.wrappedValue = .databaseError(message: NSLocalizedString("Cannot generate random database passphrase", comment: "alert message")) case let .errorSQL(_, error): alert.wrappedValue = .databaseError(message: error) case let .unknown(error): diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 7d1e5ce827..fa953b2ec1 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -50,7 +50,7 @@ struct AddChannelView: View { Spacer(minLength: 0) ZStack(alignment: .center) { ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: profile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128) + ProfileImage(imageStr: profile.image, icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: 128) if profile.image != nil { Button { profile.image = nil @@ -285,7 +285,7 @@ struct AddChannelView: View { let total = groupRelays.count return List { Group { - ProfileImage(imageStr: gInfo.groupProfile.image, iconName: "antenna.radiowaves.left.and.right.circle.fill", size: 128) + ProfileImage(imageStr: gInfo.groupProfile.image, icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: 128) .frame(maxWidth: .infinity, alignment: .center) Text(gInfo.groupProfile.displayName) diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 47afee5f06..a4f02e51ba 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -71,7 +71,7 @@ struct AddGroupView: View { Spacer(minLength: 0) ZStack(alignment: .center) { ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: profile.image, iconName: "person.2.circle.fill", size: 128) + ProfileImage(imageStr: profile.image, icon: Image(systemName: "person.2.circle.fill"), size: 128) if profile.image != nil { Button { profile.image = nil diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index ef691b10f9..b558802d36 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1129,11 +1129,9 @@ private func showPrepareContactAlert( profileImage: ProfileImage( imageStr: contactShortLinkData.profile.image, - iconName: contactShortLinkData.business - ? "briefcase.circle.fill" - : contactShortLinkData.profile.peerType == .bot - ? "cube.fill" - : "person.crop.circle.fill", + icon: contactShortLinkData.business + ? Image(systemName: "briefcase.circle.fill") + : peerTypeIcon(contactShortLinkData.profile.peerType), size: alertProfileImageSize ), profileBadge: contactShortLinkData.localBadge, @@ -1186,9 +1184,9 @@ private func showPrepareGroupAlert( profileImage: ProfileImage( imageStr: groupShortLinkData.groupProfile.image, - iconName: isChannel + icon: Image(systemName: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" - : "person.2.circle.fill", + : "person.2.circle.fill"), size: alertProfileImageSize ), theme: theme, @@ -1240,7 +1238,7 @@ private func showOpenKnownContactAlert( profileImage: ProfileImage( imageStr: contact.profile.image, - iconName: contact.chatIconName, + icon: contact.chatIcon, size: alertProfileImageSize ), profileBadge: contact.active ? contact.profile.localBadge : nil, @@ -1288,7 +1286,7 @@ private func showOpenKnownGroupAlert( profileImage: ProfileImage( imageStr: groupInfo.groupProfile.image, - iconName: groupInfo.chatIconName, + icon: Image(systemName: groupInfo.chatIconName), size: alertProfileImageSize ), theme: theme, @@ -1605,7 +1603,7 @@ func planAndConnect( profileImage: ProfileImage( imageStr: groupSLinkData.groupProfile.image, - iconName: "antenna.radiowaves.left.and.right.circle.fill", + icon: Image(systemName: "antenna.radiowaves.left.and.right.circle.fill"), size: alertProfileImageSize ), theme: theme, @@ -1632,7 +1630,7 @@ func planAndConnect( profileImage: ProfileImage( imageStr: groupSLinkData.groupProfile.image, - iconName: "person.2.circle.fill", + icon: Image(systemName: "person.2.circle.fill"), size: alertProfileImageSize ), theme: theme, diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift index 5abbbf8d2e..6a05851c63 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift @@ -12,11 +12,15 @@ import WebKit struct ConditionsWebView: UIViewRepresentable { @State var html: String + var openLink: (URL) -> Void = openExternalLink @EnvironmentObject var theme: AppTheme @State var pageLoaded = false func makeUIView(context: Context) -> WKWebView { - let view = WKWebView() + let config = WKWebViewConfiguration() + config.defaultWebpagePreferences.allowsContentJavaScript = false + config.websiteDataStore = .nonPersistent() + let view = WKWebView(frame: .zero, configuration: config) view.backgroundColor = .clear view.isOpaque = false view.navigationDelegate = context.coordinator @@ -35,8 +39,9 @@ struct ConditionsWebView: UIViewRepresentable { } private func loadPage(_ webView: WKWebView) { + let nonce = UUID().uuidString let styles = """ - """ - let head = "\(styles)" + let csp = "" + let head = "\(csp)\(styles)" webView.loadHTMLString(head + html, baseURL: nil) DispatchQueue.main.async { pageLoaded = true @@ -58,10 +77,16 @@ struct ConditionsWebView: UIViewRepresentable { } func makeCoordinator() -> Cordinator { - Cordinator() + Cordinator(openLink: openLink) } class Cordinator: NSObject, WKNavigationDelegate { + let openLink: (URL) -> Void + + init(openLink: @escaping (URL) -> Void) { + self.openLink = openLink + } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { @@ -71,9 +96,9 @@ struct ConditionsWebView: UIViewRepresentable { switch navigationAction.navigationType { case .linkActivated: decisionHandler(.cancel) - openExternalLink(url) + openLink(url) default: - decisionHandler(.allow) + decisionHandler(url.scheme == "about" ? .allow : .cancel) } } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index d10c199e78..f7042b0c39 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -49,6 +49,7 @@ let DEFAULT_SHOULD_IMPORT_APP_SETTINGS = "shouldImportAppSettings" let DEFAULT_DEVELOPER_TOOLS = "developerTools" let DEFAULT_ENCRYPTION_STARTED = "encryptionStarted" let DEFAULT_ENCRYPTION_STARTED_AT = "encryptionStartedAt" +let DEFAULT_SHOULD_DELETE_DATABASE_BACKUPS = "shouldDeleteDatabaseBackups" let DEFAULT_ACCENT_COLOR_RED = "accentColorRed" // deprecated, only used for migration let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for migration let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration @@ -83,6 +84,7 @@ let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice" let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice" let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" let DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE = "showSubscriptionPercentage" +let DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView" let DEFAULT_CURRENT_THEME = "currentTheme" let DEFAULT_SYSTEM_DARK_THEME = "systemDarkTheme" @@ -144,6 +146,7 @@ let appDefaults: [String: Any] = [ DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true, DEFAULT_SHOW_SENT_VIA_RPOXY: false, DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE: false, + DEFAULT_MARKDOWN_FILE_TEXT_VIEW: false, ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue, DEFAULT_THEME_OVERRIDES: "{}", @@ -207,6 +210,8 @@ let encryptionStartedDefault = BoolDefault(defaults: UserDefaults.standard, forK let encryptionStartedAtDefault = DateDefault(defaults: UserDefaults.standard, forKey: DEFAULT_ENCRYPTION_STARTED_AT) +let shouldDeleteDatabaseBackupsDefault = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_SHOULD_DELETE_DATABASE_BACKUPS) + let connectViaLinkTabDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CONNECT_VIA_LINK_TAB, withDefault: .scan) let privacySimplexLinkModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) diff --git a/apps/ios/Shared/Views/UserSettings/StorageView.swift b/apps/ios/Shared/Views/UserSettings/StorageView.swift index 094c1cb3d6..9d88e02be2 100644 --- a/apps/ios/Shared/Views/UserSettings/StorageView.swift +++ b/apps/ios/Shared/Views/UserSettings/StorageView.swift @@ -10,33 +10,66 @@ import SwiftUI import SimpleXChat struct StorageView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme @State var appGroupFiles: [String: Int64] = [:] @State var documentsFiles: [String: Int64] = [:] var body: some View { ScrollView { VStack(alignment: .leading) { - directoryView("App group:", appGroupFiles) + directoryView("App group:", getGroupContainerDirectory(), appGroupFiles) if !documentsFiles.isEmpty { - directoryView("Documents:", documentsFiles) + directoryView("Documents:", getDocumentsDirectory(), documentsFiles) } } } .padding() - .onAppear { - appGroupFiles = traverseFiles(in: getGroupContainerDirectory()) - documentsFiles = traverseFiles(in: getDocumentsDirectory()) - } + .onAppear(perform: loadFiles) + } + + private func loadFiles() { + appGroupFiles = traverseFiles(in: getGroupContainerDirectory()) + documentsFiles = traverseFiles(in: getDocumentsDirectory()) } @ViewBuilder - private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View { + private func directoryView(_ name: LocalizedStringKey, _ dir: URL, _ contents: [String: Int64]) -> some View { Text(name).font(.headline) ForEach(Array(contents), id: \.key) { (key, value) in - Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))) + let sizeText = Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))) + if dir.appendingPathComponent(key).path == getTempFilesDirectory().path { + let stopped = m.chatRunning == false + HStack { + sizeText + Button("Delete temp data", role: .destructive, action: confirmDeleteTempFiles) + .disabled(!stopped) + } + if !stopped { + Text("Stop chat in Settings → Chat data → Database passphrase & export to delete temp data.") + .font(.footnote) + .foregroundColor(theme.colors.secondary) + } + } else { + sizeText + } } } + private func confirmDeleteTempFiles() { + showAlert( + NSLocalizedString("Delete temp data?", comment: "alert title"), + message: NSLocalizedString("Files being sent or received will never complete. Videos in unsent drafts will be sent without the file.", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Delete", comment: "alert action"), style: .destructive) { _ in + deleteTempFiles() + loadFiles() + }, + cancelAlertAction + ]} + ) + } + private func traverseFiles(in dir: URL) -> [String: Int64] { var res: [String: Int64] = [:] let fm = FileManager.default diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 9711f3d6f8..61c808dda1 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -139,7 +139,7 @@ struct UserAddressView: View { title: Text("Share address with SimpleX contacts?"), message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."), primaryButton: .default(Text("Share")) { - setProfileAddress($progressIndicator, true) + setProfileAddress($progressIndicator, true) { shareViaProfile = false } shareViaProfile = true }, secondaryButton: .cancel() ) @@ -318,7 +318,7 @@ struct UserAddressView: View { alert = .shareOnCreate progressIndicator = false } else { - setProfileAddress($progressIndicator, true) + setProfileAddress($progressIndicator, true) { shareViaProfile = false } shareViaProfile = true } } else { @@ -601,7 +601,7 @@ struct AddressSettingsState: Equatable { } } -private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool) { +private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool, revert: @escaping () -> Void) { progressIndicator.wrappedValue = true Task { do { @@ -613,7 +613,11 @@ private func setProfileAddress(_ progressIndicator: Binding, _ on: Bool) { await MainActor.run { progressIndicator.wrappedValue = false } } catch let error { logger.error("apiSetProfileAddress: \(responseError(error))") - await MainActor.run { progressIndicator.wrappedValue = false } + await MainActor.run { + progressIndicator.wrappedValue = false + revert() + showErrorAlert(error, NSLocalizedString("Error saving profile", comment: "alert title")) + } } } } @@ -709,7 +713,10 @@ struct UserAddressSettingsView: View { title: NSLocalizedString("Share", comment: "alert action"), style: .default, handler: { _ in - setProfileAddress($progressIndicator, on) + setProfileAddress($progressIndicator, on) { + ignoreShareViaProfileChange = true + shareViaProfile = !on + } } ) ]} @@ -731,7 +738,10 @@ struct UserAddressSettingsView: View { title: NSLocalizedString("Stop sharing", comment: "alert action"), style: .default, handler: { _ in - setProfileAddress($progressIndicator, on) + setProfileAddress($progressIndicator, on) { + ignoreShareViaProfileChange = true + shareViaProfile = !on + } } ) ]} diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index a2a62b557c..64c877f9cd 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -29,14 +29,14 @@ struct UserProfile: View { var body: some View { List { - EditProfileImage(profileImage: $profile.image, iconName: "person.crop.circle.fill", showChooseSource: $showChooseSource) + EditProfileImage(profileImage: $profile.image, icon: Image(systemName: "person.crop.circle.fill"), showChooseSource: $showChooseSource) .padding(.top) Section { HStack { TextField("Enter your name…", text: $profile.displayName) .focused($focusDisplayName) - if !validDisplayName(profile.displayName) { + if !validNewProfileName { Button { alert = .invalidNameError(validName: mkValidName(profile.displayName)) } label: { @@ -147,10 +147,15 @@ struct UserProfile: View { (chatModel.currentUser?.profile.description ?? "") != description.trimmingCharacters(in: .whitespacesAndNewlines) ) && profile.displayName.trimmingCharacters(in: .whitespaces) != "" && - validDisplayName(profile.displayName) && + validNewProfileName && bioFitsLimit() } + private var validNewProfileName: Bool { + profile.displayName == chatModel.currentUser?.profile.displayName + || validDisplayName(profile.displayName) + } + private func saveProfile() { focusDisplayName = false Task { @@ -216,7 +221,7 @@ struct EditProfileImage: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner @Binding var profileImage: String? - var iconName: String + var icon: Image @Binding var showChooseSource: Bool var body: some View { @@ -232,7 +237,7 @@ struct EditProfileImage: View { } } else { ZStack(alignment: .center) { - ProfileImage(imageStr: profileImage, iconName: iconName, size: 160) + ProfileImage(imageStr: profileImage, icon: icon, size: 160) editImageButton { showChooseSource = true } } } diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift index 35a838fff9..28073ef342 100644 --- a/apps/ios/SimpleX NSE/NSEAPITypes.swift +++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift @@ -89,6 +89,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) case newChatItems(user: UserRef, chatItems: [AChatItem]) + case duplicateGroupMessage(user: UserRef, member: GroupMember) case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) @@ -101,6 +102,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case .contactConnected: "contactConnected" case .receivedContactRequest: "receivedContactRequest" case .newChatItems: "newChatItems" + case .duplicateGroupMessage: "duplicateGroupMessage" case .rcvFileSndCancelled: "rcvFileSndCancelled" case .sndFileComplete: "sndFileComplete" case .sndFileRcvCancelled: "sndFileRcvCancelled" @@ -117,6 +119,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) + case let .duplicateGroupMessage(u, member): return withUser(u, String(describing: member)) case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 25df063f82..38e1b604be 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -73,6 +73,16 @@ public enum NSENotificationData { default: nil } } + + func replaces(_ bestAttempt: NSENotificationData) -> Bool { + switch (self, bestAttempt) { + case (.noNtf, .connectionEvent): true + case (.noNtf, _): false + case (.callInvitation, _): true + case (_, .callInvitation): false + default: true + } + } } // Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid @@ -573,9 +583,9 @@ class NotificationService: UNNotificationServiceExtension { // A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above. // If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once, // instead of requesting further messages. - if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil { + if let bestAttempt = notificationEntities[id]?.msgBestAttemptNtf, ntf.replaces(bestAttempt) { notificationEntities[id]?.msgBestAttemptNtf = ntf - } // otherwise keep call as best attempt + } return false } else { // We should not get to this branch, as notifications are not delivered for muted users. @@ -1022,8 +1032,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? cItem = autoReceiveFile(file) ?? cItem } let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf - let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir { - groupMember.id + let chatIdOrMemberId = if case let .group(groupInfo, _) = cInfo, let memberId = cItem.meta.forwardedByMember ?? cItem.chatDir.groupMemberId { + groupMemberEntityId(groupInfo.groupId, memberId) } else { chatItem.chatInfo.id } @@ -1031,6 +1041,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? } else { return nil } + case let .duplicateGroupMessage(_, member): + return (member.id, .noNtf) case let .rcvFileSndCancelled(_, aChatItem, _): cleanupFile(aChatItem) return nil diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index ea1bbbd46f..bd0354ce51 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -247,6 +247,11 @@ class ShareModel: ObservableObject { title: "Keychain error", message: "Cannot access keychain to save database password" ) + case .errorKeyGeneration: + ErrorAlert( + title: "Database error", + message: "Cannot generate random database passphrase" + ) case .invalidConfirmation: ErrorAlert("Invalid migration confirmation") case let .unknown(json): diff --git a/apps/ios/SimpleX SE/ShareView.swift b/apps/ios/SimpleX SE/ShareView.swift index 3bdbc950ba..fd163f74e5 100644 --- a/apps/ios/SimpleX SE/ShareView.swift +++ b/apps/ios/SimpleX SE/ShareView.swift @@ -25,7 +25,7 @@ struct ShareView: View { HStack { profileImage( chatInfoId: chat.chatInfo.id, - iconName: chatIconName(chat.chatInfo), + icon: chatIcon(chat.chatInfo), size: 30 ) Text(chat.chatInfo.displayName).foregroundStyle( @@ -209,11 +209,11 @@ struct ShareView: View { .background(Material.ultraThin) } - @ViewBuilder private func profileImage(chatInfoId: ChatInfo.ID, iconName: String, size: Double) -> some View { + @ViewBuilder private func profileImage(chatInfoId: ChatInfo.ID, icon: Image, size: Double) -> some View { if let uiImage = model.profileImages[chatInfoId] { clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius) } else { - Image(systemName: iconName) + icon .resizable() .foregroundColor(Color(uiColor: .tertiaryLabel)) .frame(width: size, height: size) diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 72bd9b0dc3..06edbf9ce9 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -42,6 +42,16 @@ simplex + + CFBundleTypeRole + Editor + CFBundleURLName + chat.simplex.app.deeplink + CFBundleURLSchemes + + simplexchat + + ITSAppUsesNonExemptEncryption diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 6a3d4898ee..389017cce0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -166,8 +166,13 @@ 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; }; 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; }; 647C9E293061362A0032110E /* GetStakeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E283061362A0032110E /* GetStakeBanner.swift */; }; + 647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */; }; + 647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647C9E2B30666C640032110E /* BadgesHowLongView.swift */; }; + 64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */; }; + 64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; + 6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; }; 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; }; @@ -181,20 +186,18 @@ 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; - 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF1302F423300072BDE /* BadgesPayView.swift */; }; 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */; }; 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */; }; 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */; }; 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF4302F423300072BDE /* BadgeStore.swift */; }; 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */; }; - 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */; }; 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -268,6 +271,8 @@ E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; }; E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; + E5C0BBEB2F82B45500EA7527 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */; }; + E5C0BBEC2F82B45500EA7527 /* SharedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */; }; E5DBF1932F88169800E1D7FD /* ConnectBannerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */; }; E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; @@ -558,8 +563,13 @@ 647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = ""; }; 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = ""; }; 647C9E283061362A0032110E /* GetStakeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStakeBanner.swift; sourceTree = ""; }; + 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesChooseBadgeView.swift; sourceTree = ""; }; + 647C9E2B30666C640032110E /* BadgesHowLongView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowLongView.swift; sourceTree = ""; }; + 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesCheckOrderView.swift; sourceTree = ""; }; + 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPurchaseStateView.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; + 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; @@ -575,19 +585,17 @@ 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = ""; }; - 64C03BF1302F423300072BDE /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = ""; }; 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = ""; }; 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = ""; }; 64C03BF4302F423300072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = ""; }; - 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = ""; }; 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = ""; }; 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -656,6 +664,7 @@ E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = ""; }; E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = ""; }; E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = ""; }; + E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SharedAssets.xcassets; sourceTree = ""; }; E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectBannerCard.swift; sourceTree = ""; }; @@ -760,8 +769,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -848,8 +857,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.9-9mhfZbTAo5U3zLPm7EYmQQ.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.11-LxOKYAQgszM3XevVjyMHum.a */, ); path = Libraries; sourceTree = ""; @@ -947,6 +956,7 @@ 5C2E260D27A30E2400F70299 /* Views */, 5CA059C5279559F40002BEB4 /* Assets.xcassets */, E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */, + E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */, D7AA2C3429A936B400737B40 /* MediaEncryption.playground */, 5C13730C2815740A00F43030 /* DebugJSON.playground */, ); @@ -1123,6 +1133,7 @@ 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, 5C10D88928F187F300E58BF0 /* FullScreenMediaView.swift */, 648010AA281ADD15009009B9 /* CIFileView.swift */, + 6F3B2A1C2E8D4F0A9B7C1D22 /* MarkdownFileView.swift */, 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */, 3CDBCF4727FF621E00354CDD /* CILinkView.swift */, 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */, @@ -1217,14 +1228,16 @@ 64C03BF8302F423300072BDE /* Badges */ = { isa = PBXGroup; children = ( + 647C9E2A30666C640032110E /* BadgesChooseBadgeView.swift */, + 647C9E2B30666C640032110E /* BadgesHowLongView.swift */, + 64D1B0A13070AA0000C0DE01 /* BadgesCheckOrderView.swift */, + 64D1B0A33070AA0000C0DE01 /* BadgesPurchaseStateView.swift */, 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */, 64EB8A9A3054347A0089FFDF /* BadgesView.swift */, 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, - 64C03BF1302F423300072BDE /* BadgesPayView.swift */, 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */, 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */, 64C03BF4302F423300072BDE /* BadgeStore.swift */, - 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */, 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */, 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */, 64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */, @@ -1497,6 +1510,7 @@ 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */, + E5C0BBEB2F82B45500EA7527 /* SharedAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1529,6 +1543,7 @@ files = ( E5DCF9712C590272007928CC /* Localizable.strings in Resources */, E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */, + E5C0BBEC2F82B45500EA7527 /* SharedAssets.xcassets in Resources */, E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1600,6 +1615,7 @@ 5CB634A829E437960066AD6B /* PasscodeEntry.swift in Sources */, 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, + 6F3B2A1C2E8D4F0A9B7C1D21 /* MarkdownFileView.swift in Sources */, 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */, 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, @@ -1649,13 +1665,11 @@ 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */, 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */, 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, - 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */, 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */, 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */, 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */, 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */, 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */, - 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */, 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */, @@ -1672,6 +1686,10 @@ CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */, 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */, 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */, + 647C9E2C30666C640032110E /* BadgesChooseBadgeView.swift in Sources */, + 647C9E2D30666C640032110E /* BadgesHowLongView.swift in Sources */, + 64D1B0A23070AA0000C0DE01 /* BadgesCheckOrderView.swift in Sources */, + 64D1B0A43070AA0000C0DE01 /* BadgesPurchaseStateView.swift in Sources */, 5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */, 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */, 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */, @@ -2139,7 +2157,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2189,7 +2207,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2231,7 +2249,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2251,7 +2269,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2276,7 +2294,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2313,7 +2331,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2350,7 +2368,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2401,7 +2419,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2455,7 +2473,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2489,7 +2507,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index bc23e53011..04e5973085 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -32,7 +32,12 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio } else if useKeychain { if !hasDatabase() { logger.debug("chatMigrateInit generating a random DB key") - dbKey = randomDatabasePassword() + guard let key = randomDatabasePassword() else { + let result = (false, DBMigrationResult.errorKeyGeneration) + migrationResult = result + return result + } + dbKey = key initialRandomDBPassphraseGroupDefault.set(true) } else if let key = kcDatabasePassword.get() { dbKey = key @@ -56,7 +61,7 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation: MigrationConfirmation = .error) -> (DBMigrationResult, chat_ctrl?) { let dbPath = url.path - let dbKey = key ?? randomDatabasePassword() + guard let dbKey = key ?? randomDatabasePassword() else { return (.errorKeyGeneration, nil) } logger.debug("chatInitTemporaryDatabase path: \(dbPath)") var temporaryController: chat_ctrl? = nil var cPath = dbPath.cString(using: .utf8)! @@ -66,14 +71,14 @@ public func chatInitTemporaryDatabase(url: URL, key: String? = nil, confirmation return (dbMigrationResult(dataFromCString(cjson)), temporaryController) } -public func chatInitControllerRemovingDatabases() { +public func chatInitControllerRemovingDatabases() throws { let dbPath = getAppDatabasePath().path let fm = FileManager.default // Remove previous databases, otherwise, can be .errorNotADatabase with nil controller try? fm.removeItem(atPath: dbPath + CHAT_DB) try? fm.removeItem(atPath: dbPath + AGENT_DB) - let dbKey = randomDatabasePassword() + guard let dbKey = randomDatabasePassword() else { throw RuntimeError("Cannot generate random database passphrase") } logger.debug("chatInitControllerRemovingDatabases path: \(dbPath)") var cPath = dbPath.cString(using: .utf8)! var cKey = dbKey.cString(using: .utf8)! @@ -144,12 +149,15 @@ public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTi } public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? { + parseSimpleXMarkdownText(s)?.formattedText +} + +public func parseSimpleXMarkdownText(_ s: String) -> ParsedMarkdown? { var c = s.cString(using: .utf8)! if let cjson = chat_parse_markdown(&c) { if let d = dataFromCString(cjson) { do { - let r = try jsonDecoder.decode(ParsedMarkdown.self, from: d) - return r.formattedText + return try jsonDecoder.decode(ParsedMarkdown.self, from: d) } catch { logger.error("parseSimpleXMarkdown jsonDecoder.decode error: \(error.localizedDescription)") } @@ -171,8 +179,9 @@ public func parseBadgeCode(_ s: String) -> String? { return canonical.isEmpty ? nil : canonical } -struct ParsedMarkdown: Decodable { - var formattedText: [FormattedText]? +public struct ParsedMarkdown: Decodable { + public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? } public func parseServerAddress(_ s: String) -> ServerAddress? { @@ -353,6 +362,7 @@ public enum DBMigrationResult: Decodable, Equatable { case errorMigration(dbFile: String, migrationError: MigrationError) case errorSQL(dbFile: String, migrationSQLError: String) case errorKeychain + case errorKeyGeneration case unknown(json: String) } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 75d66ebb6f..6f5f62daf3 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -598,6 +598,7 @@ public enum RcvSwitchStatus: String, Decodable, Hashable { case sendingQADD = "sending_qadd" case sendingQUSE = "sending_quse" case receivedMessage = "received_message" + case receivedQEND = "received_qend" } public struct SndQueueInfo: Decodable, Hashable { @@ -826,6 +827,7 @@ public enum ChatErrorType: Decodable, Hashable { public enum BadgeRedeemError: Decodable, Hashable { case invalidCode + case invalidReceipt case serviceNotConfigured case badgeActive case serviceError(serviceError: BadgeServiceErrorCode) @@ -846,12 +848,14 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case paymentNotEntitled case paymentPending case providerUnavailable + case providerNotConfigured case rateLimited case codeInvalid case codeUsed case codeExpired case receiptInvalid case receiptUsed + case testPurchase case internalError case unknown(String) @@ -867,11 +871,13 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case .paymentNotEntitled: "payment_not_entitled" case .paymentPending: "payment_pending" case .providerUnavailable: "provider_unavailable" + case .providerNotConfigured: "provider_not_configured" case .rateLimited: "rate_limited" case .codeInvalid: "code_invalid" case .codeUsed: "code_used" case .codeExpired: "code_expired" case .receiptInvalid: "receipt_invalid" + case .testPurchase: "test_purchase" case .receiptUsed: "receipt_used" case .internalError: "internal" case let .unknown(s): s @@ -890,11 +896,13 @@ public enum BadgeServiceErrorCode: Decodable, Hashable { case "payment_not_entitled": self = .paymentNotEntitled case "payment_pending": self = .paymentPending case "provider_unavailable": self = .providerUnavailable + case "provider_not_configured": self = .providerNotConfigured case "rate_limited": self = .rateLimited case "code_invalid": self = .codeInvalid case "code_used": self = .codeUsed case "code_expired": self = .codeExpired case "receipt_invalid": self = .receiptInvalid + case "test_purchase": self = .testPurchase case "receipt_used": self = .receiptUsed case "internal": self = .internalError case let s: self = .unknown(s) @@ -911,8 +919,14 @@ public func badgeServiceErrorText(_ code: BadgeServiceErrorCode) -> String? { case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message") case .unknownPurchaseKey: NSLocalizedString("The badge service does not recognize this badge.", comment: "alert message") case .internalError: NSLocalizedString("The badge service reported an internal error.", comment: "alert message") - case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .productUnavailable, - .paymentNotEntitled, .paymentPending, .providerUnavailable, .receiptInvalid, .receiptUsed, .unknown: nil + case .receiptInvalid: NSLocalizedString("The store did not confirm this purchase.", comment: "alert message") + case .testPurchase: NSLocalizedString("Sandbox purchases are not credited. Badges can only be bought in the App Store version of the app.", comment: "alert message") + case .receiptUsed: NSLocalizedString("This purchase was already credited to a badge on another device or profile.", comment: "alert message") + case .paymentPending: NSLocalizedString("The payment has not been confirmed yet.", comment: "alert message") + case .providerUnavailable: NSLocalizedString("The store could not be reached.", comment: "alert message") + case .providerNotConfigured: NSLocalizedString("Badges cannot be bought in the app yet.", comment: "alert message") + case .productUnavailable: NSLocalizedString("This badge is not on sale.", comment: "alert message") + case .badRequest, .unknownOfferId, .offerDisabled, .offerMismatch, .paymentNotEntitled, .unknown: nil } } diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index ece65130e6..53830bc68f 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -34,10 +34,12 @@ public struct WebRTCSession: Codable { // Spec: spec/services/calls.md#WebRTCExtraInfo public struct WebRTCExtraInfo: Codable { - public init(rtcIceCandidates: String) { + public init(rtcSession: String?, rtcIceCandidates: String) { + self.rtcSession = rtcSession self.rtcIceCandidates = rtcIceCandidates } + public var rtcSession: String? public var rtcIceCandidates: String } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9dbbffd4ce..8bdd952372 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -133,6 +133,7 @@ public struct Profile: Codable, NamedChat, Hashable { self.image = image self.contactLink = contactLink self.preferences = preferences + self.peerType = peerType self.contactDomain = contactDomain } @@ -227,6 +228,7 @@ public enum ChatPeerType: Hashable { case human case bot case business + case agent case unknown(String) public var text: String { @@ -234,6 +236,7 @@ public enum ChatPeerType: Hashable { case .human: "human" case .bot: "bot" case .business: "business" + case .agent: "agent" case let .unknown(s): s } } @@ -245,6 +248,7 @@ extension ChatPeerType: Codable { case "human": self = .human case "bot": self = .bot case "business": self = .business + case "agent": self = .agent case let s: self = .unknown(s) } } @@ -255,6 +259,14 @@ extension ChatPeerType: Codable { } } +public func peerTypeIcon(_ peerType: ChatPeerType?) -> Image { + switch peerType { + case .bot: Image(systemName: "cube.fill") + case .agent: Image("agent") + default: Image(systemName: "person.crop.circle.fill") + } +} + // Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. // Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. public enum BadgeType: Hashable { @@ -368,6 +380,24 @@ public enum BadgeIssueFailure: Decodable, Hashable { } } + public var purchaseText: String { + switch self { + case let .serviceError(code, _): + badgeServiceErrorText(code) + ?? String.localizedStringWithFormat(NSLocalizedString("The badge service refused the purchase: %@", comment: "badge purchase error"), code.text) + case .invalidCredential: NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message") + case .serviceTimeout, .network, .unexpected: text + } + } + + public var purchaseAlertTitle: String { + switch self { + case .serviceError(.testPurchase, _): + NSLocalizedString("Sandbox purchase", comment: "alert title") + default: NSLocalizedString("Purchase error", comment: "alert title") + } + } + // the stored form, for support public var tag: String { switch self { @@ -583,7 +613,8 @@ public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias contactLink: profile.contactLink, preferences: profile.preferences, peerType: profile.peerType, - localAlias: localAlias + localAlias: localAlias, + contactDomain: profile.contactDomain ) } @@ -2512,8 +2543,8 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { activeConn?.customUserProfileId != nil } - public var chatIconName: String { - isBot ? "cube.fill" : "person.crop.circle.fill" + public var chatIcon: Image { + peerTypeIcon(profile.peerType) } public func allowsFeature(_ feature: ChatFeature) -> Bool { @@ -3253,7 +3284,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var relayLink: String? public var memberVerifiedCode: SecurityCode? - public var id: String { "#\(groupId) @\(groupMemberId)" } + public var id: String { groupMemberEntityId(groupId, groupMemberId) } public var ready: Bool { get { activeConn?.connStatus == .ready } } public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } } public var sendMsgEnabled: Bool { get { @@ -3442,6 +3473,10 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ) } +public func groupMemberEntityId(_ groupId: Int64, _ groupMemberId: Int64) -> String { + "#\(groupId) @\(groupMemberId)" +} + public struct GroupSupportChat: Codable, Hashable { public var chatTs: Date public var unread: Int @@ -3758,11 +3793,12 @@ public struct CIReaction: Decodable, Hashable { } public struct ChatItem: Identifiable, Decodable, Hashable { - public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { + public init(chatDir: CIDirection, meta: CIMeta, content: CIContent, formattedText: [FormattedText]? = nil, markdownBlocks: [MarkdownBlock]? = nil, mentions: [String: CIMention]? = nil, quotedItem: CIQuote? = nil, reactions: [CIReactionCount] = [], file: CIFile? = nil) { self.chatDir = chatDir self.meta = meta self.content = content self.formattedText = formattedText + self.markdownBlocks = markdownBlocks self.mentions = mentions self.quotedItem = quotedItem self.reactions = reactions @@ -3773,6 +3809,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var meta: CIMeta public var content: CIContent public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? public var mentions: [String: CIMention]? public var quotedItem: CIQuote? public var reactions: [CIReactionCount] @@ -3782,7 +3819,7 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var isLiveDummy: Bool = false private enum CodingKeys: String, CodingKey { - case chatDir, meta, content, formattedText, mentions, quotedItem, reactions, file + case chatDir, meta, content, formattedText, markdownBlocks, mentions, quotedItem, reactions, file } public var id: Int64 { meta.itemId } @@ -4215,6 +4252,10 @@ public enum CIDirection: Decodable, Hashable { } } + public var groupMemberId: Int64? { + if case let .groupRcv(groupMember) = self { groupMember.groupMemberId } else { nil } + } + public func sameDirection(_ dir: CIDirection) -> Bool { switch (self, dir) { case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId @@ -4240,6 +4281,7 @@ public struct CIMeta: Decodable, Hashable { public var userMention: Bool public var deletable: Bool public var editable: Bool + public var forwardedByMember: Int64? public var showGroupAsSender: Bool public var msgVerified: MsgVerified? @@ -4819,6 +4861,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable { public var sentAt: Date public var content: MsgContent public var formattedText: [FormattedText]? + public var markdownBlocks: [MarkdownBlock]? public var text: String { switch (content.text, content) { case let ("", .voice(_, duration)): return durationText(duration) @@ -5632,14 +5675,119 @@ public struct FormattedText: Decodable, Hashable { public var linkUri: String? { switch format { case .uri: text - case let .hyperLink(_, linkUri): linkUri + case let .hyperLink(_, linkUri): isEmailLink ? nil : linkUri default: nil } } + + public var isEmailLink: Bool { + if case let .hyperLink(_, linkUri) = format { linkUri.hasPrefix("mailto:") } else { false } + } +} + +public enum BlockToken: Decodable, Hashable { + case lineBreak + case softBreak + case hardBreak + case paragraphBreak(lineBreaks: Int) + case indent + case headingMark(level: Int) + case headingClose + case headingUnderline(level: Int) + case listMark(number: String?) + case quoteMark + case fenceMark(language: String?) + case rowMark + case cellMark + case delimiterRow(columns: [TableAlign]) + case ruleMark + + public var drawnText: String { + switch self { + case .softBreak: " " + case .lineBreak, .hardBreak: "\n" + case let .paragraphBreak(lineBreaks): String(repeating: "\n", count: lineBreaks) + default: "" + } + } + + public var previewText: String { + switch self { + case .softBreak, .hardBreak, .cellMark: " " + case .lineBreak, .paragraphBreak: "\n" + case let .listMark(number): (number ?? "•") + " " + default: "" + } + } +} + +public enum MarkdownBlock: Decodable, Hashable { + case paragraph(content: SegmentRange) + case heading(level: Int, content: SegmentRange) + case codeBlock(language: String?, content: SegmentRange) + case quote(content: SegmentRange) + case list(items: [ListEntry]) + case table(columns: [TableAlign], header: [SegmentRange], rows: [[SegmentRange]]) + case rule(mark: SegmentRange) +} + +public struct ListEntry: Decodable, Hashable { + public var number: String? + public var content: SegmentRange + public var items: [ListEntry] +} + +public struct SegmentRange: Decodable, Hashable { + public var from: Int + public var to: Int +} + +public enum TableAlign: String, Decodable, Hashable { + case none + case left + case center + case right +} + +public struct BlockTextRange { + public var range: SegmentRange + public var inRow: Bool +} + +extension MarkdownBlock { + public var textRanges: [BlockTextRange] { + switch self { + case let .paragraph(content), let .heading(_, content), let .codeBlock(_, content), let .quote(content): + [BlockTextRange(range: content, inRow: false)] + case let .list(items): + listEntries(items, 0).map { BlockTextRange(range: $0.entry.content, inRow: false) } + case let .table(_, header, rows): + ([header] + rows).flatMap { row in row.enumerated().map { c, cell in BlockTextRange(range: cell, inRow: c > 0) } } + case .rule: + [] + } + } +} + +public func listEntries(_ items: [ListEntry], _ level: Int) -> [(entry: ListEntry, level: Int)] { + items.flatMap { [(entry: $0, level: level)] + listEntries($0.items, level + 1) } +} + +public func blockTextRanges(_ blocks: [MarkdownBlock]) -> [BlockTextRange] { + blocks.flatMap { $0.textRanges } +} + +public func headingSegments(_ size: Int, _ blocks: [MarkdownBlock]) -> [Bool] { + var bold = [Bool](repeating: false, count: size) + for case let .heading(_, content) in blocks { + let from = min(max(content.from, 0), size) + for i in from.. Bool { } } -public func chatIconName(_ cInfo: ChatInfo) -> String { +public func chatIcon(_ cInfo: ChatInfo) -> Image { switch cInfo { - case let .direct(contact): contact.chatIconName - case let .group(groupInfo, _): groupInfo.chatIconName - case .local: "folder.circle.fill" - case .contactRequest: "person.crop.circle.fill" - default: "circle.fill" + case let .direct(contact): contact.chatIcon + case let .group(groupInfo, _): Image(systemName: groupInfo.chatIconName) + case .local: Image(systemName: "folder.circle.fill") + case .contactRequest: Image(systemName: "person.crop.circle.fill") + default: Image(systemName: "circle.fill") } } diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 69bf305147..ba40a6817d 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -140,6 +140,16 @@ public func deleteAppFiles() { } } +public func deleteTempFiles() { + let fm = FileManager.default + do { + try fm.removeItem(at: getTempFilesDirectory()) + try fm.createDirectory(at: getTempFilesDirectory(), withIntermediateDirectories: true) + } catch { + logger.error("FileUtils deleteTempFiles error: \(error.localizedDescription)") + } +} + public func fileSize(_ url: URL) -> Int? { // in bytes do { let val = try url.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]) @@ -194,6 +204,13 @@ private func restoreBackupFile(fromPath: String, toPath: String) throws { try fm.copyItem(atPath: fromPath, toPath: toPath) } +public func deleteDatabaseBackups() { + let fm = FileManager.default + let dbPath = getAppDatabasePath().path + try? fm.removeItem(atPath: dbPath + CHAT_DB_BAK) + try? fm.removeItem(atPath: dbPath + AGENT_DB_BAK) +} + public func hasLegacyDatabase() -> Bool { hasDatabaseAtPath(getLegacyDatabasePath()) } diff --git a/apps/ios/SimpleXChat/KeyChain.swift b/apps/ios/SimpleXChat/KeyChain.swift index 50a5946462..7806595ecf 100644 --- a/apps/ios/SimpleXChat/KeyChain.swift +++ b/apps/ios/SimpleXChat/KeyChain.swift @@ -37,7 +37,7 @@ public struct KeyChainItem { } } -func randomDatabasePassword() -> String { +func randomDatabasePassword() -> String? { var keyData = Data(count: 32) let status = keyData.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!) @@ -46,7 +46,7 @@ func randomDatabasePassword() -> String { return keyData.base64EncodedString() } else { logger.error("randomDatabasePassword: error \(status)") - return "" + return nil } } diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index a40e8eda99..be6245da7d 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -138,7 +138,7 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> title = NSLocalizedString("Encrypted message: no passphrase", comment: "notification") case .errorMigration: title = NSLocalizedString("Encrypted message: database migration error", comment: "notification") - case .errorSQL: + case .errorSQL, .errorKeyGeneration: title = NSLocalizedString("Encrypted message: database error", comment: "notification") case .errorKeychain: title = NSLocalizedString("Encrypted message: keychain error", comment: "notification") @@ -201,10 +201,11 @@ func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String { if let md = cItem.formattedText { var res = "" for ft in md { - if case .secret = ft.format { - res = res + "..." - } else { - res = res + ft.text + switch ft.format { + case .secret: res = res + "..." + case let .markup(token): res = res + token.previewText + case .escape: () + default: res = res + ft.text } } return res diff --git a/apps/ios/product/flows/calling.md b/apps/ios/product/flows/calling.md index 86cb026625..e656a2932a 100644 --- a/apps/ios/product/flows/calling.md +++ b/apps/ios/product/flows/calling.md @@ -103,6 +103,14 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central 5. `CallViewRenderers` manages WebRTC video rendering surfaces. 6. Call status updates are sent via `apiCallStatus(contact:status:)`. +### 6.1 Reconnection + +1. A connected call loses its connection, e.g. when the device switches between Wi-Fi and mobile network. +2. The call shows "reconnecting…" and plays the connecting sound. Call duration continues, as this state is not reported to the core. +3. The callee (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer via `apiSendCallExtraInfo` with `rtcSession`; the caller answers the same way. +4. When the connection is restored, the call returns to connected state. +5. If the connection is not restored in 30 seconds, the call ends. Clients without ICE restart support ignore the restart offer, so the call ends in this case. + ### 7. Audio Routing 1. `CallAudioDeviceManager` handles audio device selection. @@ -141,7 +149,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central | `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs | | `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data | | `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) | -| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer | +| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer, and ICE restart offer/answer | | `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting | | `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` | | `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera | @@ -152,7 +160,7 @@ All signaling messages are exchanged via E2E encrypted SMP messages (no central |-------|-------|----------| | Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout | | Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry | -| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status | +| WebRTC peer connection failure | NAT traversal, network issues | Before connection: call ends with error status; after connection: reconnection with ICE restart, call ends if not reconnected in 30 seconds | | CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up | | No camera/mic permission | User denied permissions | Permission request dialog shown | diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index 56c3c34cb6..cefc76dd19 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -179,7 +179,7 @@ The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.sw | `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) | | `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) | | `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) | -| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) | +| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates or ICE restart offer/answer | [L171](../Shared/Model/AppAPITypes.swift#L171) | | `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) | | `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) | | `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) | diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index c509662c9e..38db89bea0 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -189,7 +189,7 @@ Renders emoji-only messages (messages containing only emoji characters) in a lar **File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) -Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). +Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). Blocks of `markdownBlocks` (headings, lists, quotes, code blocks, tables, rules) are drawn as a column of views by `MarkdownBlocksView`; code blocks and tables scroll sideways. ### DeletedItemView / MarkedDeletedItemView @@ -238,6 +238,12 @@ Renders voice messages with waveform visualization, play/pause control, and dura Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download. +### MarkdownFileView + +**File**: [`Shared/Views/Chat/ChatItem/MarkdownFileView.swift`](../../Shared/Views/Chat/ChatItem/MarkdownFileView.swift) + +Sheet with a viewer of Markdown files up to `MAX_MARKDOWN_VIEW_SIZE` (1 MB), formatted or as text, with Save. Opened by a tap on the file item, or when reception completes after the tap that started it. + ### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) **File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md index 64d0940e39..81965dcd8d 100644 --- a/apps/ios/spec/client/navigation.md +++ b/apps/ios/spec/client/navigation.md @@ -200,6 +200,8 @@ SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Sh URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1181). +App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectViaUrl_()` branches on that scheme before the path switch, so an app link never reaches `planAndConnect()`, and `openAppLink()` dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to `openBadgeLink()` in [`BadgesRedeemCodeView.swift`](../../Shared/Views/Badges/BadgesRedeemCodeView.swift), which presents `BadgesRedeemLinkView` as an app sheet. The sheet names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the sheet renders), which core would refuse, the sheet does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or a swipe closes the sheet without cancelling anything; if the redemption then succeeds with the sheet closed or covered, an alert says the badge was added and names the profile, unless the profile is hidden and no longer active, when nothing is shown (`showBadgeAddedAlert()`, which the store lane shares). While the sheet shows a redemption in flight (`isBadgeLinkIssuing()`), `connectViaUrl_()` ignores a further app link; otherwise it dismisses the sheets as for any link, which replaces an earlier badge link sheet. Any other path is a link type from a later version, and gets an alert asking to check for app updates. An app link can carry a secret, so `onOpenURL` logs it as "app link" rather than the URL. + ### Call Deep Link Call invitations from notifications: diff --git a/apps/ios/spec/database.md b/apps/ios/spec/database.md index cb08abe2aa..14fee1cb1c 100644 --- a/apps/ios/spec/database.md +++ b/apps/ios/spec/database.md @@ -134,6 +134,7 @@ Migration results are decoded in Swift as `DBMigrationResult`: - `.errorMigration(dbFile:, migrationError:)` -- migration failed - `.errorSQL(dbFile:, migrationSQLError:)` -- SQL error during migration - `.errorKeychain` -- keychain access failed +- `.errorKeyGeneration` -- random database key generation failed - `.unknown(json:)` -- unrecognized response --- diff --git a/apps/ios/spec/services/calls.md b/apps/ios/spec/services/calls.md index 6a1d89f6a3..b3fb83d151 100644 --- a/apps/ios/spec/services/calls.md +++ b/apps/ios/spec/services/calls.md @@ -116,7 +116,10 @@ Manages the WebRTC peer connection, media streams, and data channels. | [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 | | [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 | | [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 | -| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 | +| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L409) | Sends gathered ICE candidates | L409 | +| [`startReconnecting`](../../Shared/Views/Call/WebRTCClient.swift#L313) | Reports "reconnecting", starts reconnection timeout and, for the party that sent the initial offer, periodic ICE restarts | L313 | +| [`restartIce`](../../Shared/Views/Call/WebRTCClient.swift#L339) | Creates ICE restart offer and sends it with candidates in `.ice(iceCandidates:session:)` | L339 | +| [`receiveRestartSession`](../../Shared/Views/Call/WebRTCClient.swift#L350) | Applies ICE restart offer (and sends the answer) or answer received in `.ice` command | L350 | | [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 | | [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 | | [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 | @@ -179,6 +182,10 @@ All call signaling travels through the same encrypted SMP message channels used 5. P2P connection established ``` +### Reconnection + +When a connected call loses ICE connection (`.disconnected` or `.failed`), `WebRTCClient` reports `"reconnecting"` connection state instead of ending the call. `ActiveCallView` sets `.reconnecting` call state and plays the connecting sound; this state is not sent to the core via `apiCallStatus`, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds: the restart offer and answer are sent via `apiSendCallExtraInfo` with `rtcSession` (`x.call.extra`). When ICE connects again, the call returns to `.connected`. If it does not reconnect in 30 seconds, `WebRTCClient` reports `"disconnected"` and ends the call. + ### API Commands | Command | Direction | Purpose | @@ -187,7 +194,7 @@ All call signaling travels through the same encrypted SMP message channels used | `apiRejectCall(contact:)` | Callee -> Caller | Reject call | | `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer | | `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer | -| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates | +| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates, or ICE restart offer/answer in `rtcSession` | | `apiEndCall(contact:)` | Either | End call | | `apiGetCallInvitations` | -- | Get pending invitations | | `apiCallStatus(contact:, callStatus:)` | -- | Report status change | @@ -317,10 +324,11 @@ struct WebRTCSession { } ``` -### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42) +### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L45) ```swift struct WebRTCExtraInfo { + var rtcSession: String? // ICE restart offer or answer var rtcIceCandidates: String // Additional ICE candidates } ``` @@ -330,7 +338,7 @@ struct WebRTCExtraInfo { Stored in `ChatModel.activeCall`: - Contact reference - Call UUID -- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.) +- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, `.reconnecting`, etc.) - Media type - WebRTCClient reference diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md index 30462d18e8..d27b4b9718 100644 --- a/apps/multiplatform/CODE.md +++ b/apps/multiplatform/CODE.md @@ -285,9 +285,13 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file) | Source Location | Spec Document | Product Document | |----------------|---------------|-----------------| -| desktop/.../desktop/Main.kt | spec/architecture.md | product/flows/onboarding.md | +| desktop/.../desktop/Main.kt | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md | | common/.../common/DesktopApp.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md | | common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md | +| common/.../common/AppLinks.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md | +| common/.../common/SingleInstance.kt (desktopMain) | spec/architecture.md, spec/client/navigation.md | product/flows/onboarding.md | +| common/.../common/WindowActivation.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md | +| common/.../common/platform/AppLinkScheme.desktop.kt (desktopMain) | spec/client/navigation.md | product/views/settings.md | | common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md | | common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md | | common/.../common/platform/AnimatedImage.desktop.kt (desktopMain) | spec/client/chat-view.md | product/views/chat.md | diff --git a/apps/multiplatform/README.md b/apps/multiplatform/README.md index 0d3c5b24f4..8fc0f701f3 100644 --- a/apps/multiplatform/README.md +++ b/apps/multiplatform/README.md @@ -101,7 +101,7 @@ Tests are in: ## Resources & Localization -- String resources: `common/src/commonMain/resources/MR/base/strings.xml` + 21 language variants +- String resources: `common/src/commonMain/moko-resources/base/strings.xml` + 21 language variants - Uses Moko Resources (`dev.icerock.moko:resources`) for cross-platform resource management - The `adjustFormatting` gradle task validates string resources during build diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 419fef90b9..c16aced912 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -9,13 +9,13 @@ plugins { } android { - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "chat.simplex.app" namespace = "chat.simplex.app" minSdk = 26 - targetSdk = 35 + targetSdk = 36 // !!! // skip version code after release to F-Droid, as it uses two version codes versionCode = (extra["android.version_code"] as String).toInt() diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt index 1d8fc5e151..0cad378da6 100644 --- a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -5,6 +5,7 @@ import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt // Play Billing is only in the google flavor, so the Play country stays unknown here fun loadPlayStoreCountry() {} @@ -22,3 +23,11 @@ suspend fun loadBadgeProducts(oneTimeIds: List, subscriptio @Suppress("UNUSED_PARAMETER") suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable + +@Suppress("UNUSED_PARAMETER") +suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} + +@Suppress("UNUSED_PARAMETER") +suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {} + +suspend fun unfinishedBadgePurchases(): List = emptyList() diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index 4075421e52..85b9da1316 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -5,6 +5,7 @@ import chat.simplex.common.platform.androidAppContext import chat.simplex.common.platform.androidPlayStoreCountry import chat.simplex.common.platform.mainActivity import chat.simplex.common.views.badges.* +import chat.simplex.common.views.helpers.withLongRunningApi import com.android.billingclient.api.* import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -40,7 +41,7 @@ fun loadPlayStoreCountry() { // One long-lived client for badges: ProductDetails obtained from it are passed back to it when the // purchase is launched, and the purchase result arrives on its listener rather than as a return value. -// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher +// volatile: written by the purchase on a background dispatcher, read by the listener on another thread @Volatile private var badgeBillingClient: BillingClient? = null @Volatile private var badgeOffers: Map = emptyMap() @Volatile private var badgePurchase: CompletableDeferred? = null @@ -80,8 +81,7 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable val client = connectedBadgeBillingClient() val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId) - val details = offer.details - val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details) + val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(offer.details) offer.offerToken?.let { productParams.setOfferToken(it) } val params = BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productParams.build())) @@ -94,19 +94,42 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc if (launched.responseCode != BillingClient.BillingResponseCode.OK) { throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage) } - val outcome = purchase.await() - if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt) - return outcome + return purchase.await() } finally { badgePurchase = null } } +// one-time purchases Play still holds: bought and not consumed, or awaiting a slow payment +suspend fun unfinishedBadgePurchases(): List { + val client = connectedBadgeBillingClient() + val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build() + val queried = CompletableDeferred>() + client.queryPurchasesAsync(params) { result, purchases -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + queried.complete(purchases) + } else { + queried.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage)) + } + } + return queried.await().mapNotNull(::badgePurchaseOutcome) +} + private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> - val pending = badgePurchase ?: return@PurchasesUpdatedListener + val pending = badgePurchase + if (pending == null) { + // settled outside a purchase call, such as a slow payment completing + if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcile(it) } } + } + return@PurchasesUpdatedListener + } when { - result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> - pending.complete(badgePurchaseOutcome(purchases)) + result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> { + val outcomes = purchases.mapNotNull(::badgePurchaseOutcome) + pending.complete(outcomes.firstOrNull() ?: BadgePurchaseOutcome.Cancelled) + withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcile(it) } } + } result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> pending.complete(BadgePurchaseOutcome.Cancelled) else -> @@ -114,17 +137,17 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p } } -private fun badgePurchaseOutcome(purchases: List): BadgePurchaseOutcome { - val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled - if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending - return BadgePurchaseOutcome.Purchased( +private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) { + Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending(purchase.accountIdentifiers?.obfuscatedAccountId) + Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( token = purchase.purchaseToken, productId = purchase.products.firstOrNull() ?: "", - orderId = purchase.orderId, - invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId + invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId, + acknowledged = purchase.isAcknowledged ) ) + else -> null } private suspend fun connectedBadgeBillingClient(): BillingClient { @@ -203,21 +226,34 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? { return BadgeOffer(id, product, this, offer.offerToken) } -// nothing is delivered in this build, so the purchase is finished right away; once the service issues -// credentials it must only be finished after the credential is stored. One-time products are consumed -// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged -// purchase after 3 days. -private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) { - val done = CompletableDeferred() - if (details.productType == BillingClient.ProductType.SUBS) { - val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build() - client.acknowledgePurchase(params) { done.complete(it) } - } else { +// acknowledged without consuming, so Play stops its 3-day refund clock and still lists the purchase until it is finished +suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) { + if (!receipt.acknowledged) acknowledge(connectedBadgeBillingClient(), receipt.token) +} + +// consumed if one-time so it can be bought again, else acknowledged, as Play refunds an unacknowledged purchase +// after 3 days; decided by product id, as after a restart there is no ProductDetails for the purchase +suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) { + val client = connectedBadgeBillingClient() + if (receipt.productId in badgeOneTimeProductIds) { + val done = CompletableDeferred() val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build() client.consumeAsync(params) { result, _ -> done.complete(result) } - } - val result = done.await() - if (result.responseCode != BillingClient.BillingResponseCode.OK) { - Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}") + checkBillingResult(done.await()) + } else { + acknowledge(client, receipt.token) + } +} + +private suspend fun acknowledge(client: BillingClient, token: String) { + val done = CompletableDeferred() + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build() + client.acknowledgePurchase(params) { done.complete(it) } + checkBillingResult(done.await()) +} + +private fun checkBillingResult(result: BillingResult) { + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage) } } diff --git a/apps/multiplatform/android/src/main/AndroidManifest.xml b/apps/multiplatform/android/src/main/AndroidManifest.xml index 64d7a03cf7..1cce1126a9 100644 --- a/apps/multiplatform/android/src/main/AndroidManifest.xml +++ b/apps/multiplatform/android/src/main/AndroidManifest.xml @@ -35,6 +35,8 @@ + + android:enableOnBackInvokedCallback="false" + android:theme="@style/Theme.SimpleX" + tools:ignore="UnusedAttribute"> @@ -70,6 +74,15 @@ + + + + + + + + + diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt index 6c3d96bebc..96403588ac 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/CallService.kt @@ -16,7 +16,7 @@ import chat.simplex.app.views.call.CallActivity import chat.simplex.common.model.NotificationPreviewMode import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Instant class CallService: Service() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 7415b6015d..d1022818ac 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.lang.ref.WeakReference class MainActivity: FragmentActivity() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 1a4c836b2e..9093b1427b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -28,7 +28,9 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStore import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt import chat.simplex.common.views.call.* import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* @@ -113,6 +115,7 @@ class SimplexApp: Application(), LifecycleEventObserver { } }.onFailure { Log.e(TAG, it.stackTraceToString()) } } + BadgeStore.presentUnfinished() } } Lifecycle.Event.ON_RESUME -> { @@ -352,6 +355,12 @@ class SimplexApp: Application(), LifecycleEventObserver { override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId) + override suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) = acknowledgeBadgePurchase(receipt) + + override suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) = finishBadgePurchase(receipt) + + override suspend fun androidUnfinishedBadgePurchases(): List = unfinishedBadgePurchases() + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index 289ecc0a31..7a6d9db47a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -29,7 +29,7 @@ import chat.simplex.common.model.NotificationsMode import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import kotlinx.coroutines.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlin.system.exitProcess diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt index 5ce69aeec2..96ebd6179a 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt @@ -22,7 +22,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.call.CallMediaType import chat.simplex.common.views.call.RcvCallInvitation import kotlinx.datetime.Clock -import chat.simplex.res.MR +import chat.simplex.res.* object NtfManager { const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION" diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt index 34a2c96b6d..e1edc5ebe3 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt @@ -36,6 +36,7 @@ import chat.simplex.app.R import chat.simplex.app.TAG import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.AcceptCallAction +import chat.simplex.common.AppLock import chat.simplex.common.helpers.applyAppLocale import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs @@ -44,7 +45,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.rememberMultiplePermissionsState import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.launch @@ -342,7 +343,10 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo chatModel.activeCallInvitation.value = null ntfManager.cancelCallNotification() }, - acceptCall = { cm.acceptIncomingCall(invitation = invitation) }, + acceptCall = { + AppLock.recheckAuthState() + cm.acceptIncomingCall(invitation = invitation) + }, openApp = { val intent = Intent(context, MainActivity::class.java) .setAction(NtfManager.OpenChatAction) diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index 64dd34afbf..ee64060206 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -48,7 +48,7 @@ buildscript { dependencies { classpath("com.android.tools.build:gradle:${rootProject.extra["gradle.plugin.version"]}") classpath(kotlin("gradle-plugin", version = rootProject.extra["kotlin.version"] as String)) - classpath("dev.icerock.moko:resources-generator:0.23.0") + classpath("dev.icerock.moko:resources-generator:0.27.1") // Workaround gradle sync issue: https://github.com/gmazzo/gradle-buildconfig-plugin/issues/137#issuecomment-1935739759 classpath("com.squareup:kotlinpoet:1.16.0") diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index a03a7d4b27..e9503bc6ce 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -56,11 +56,6 @@ kotlin { } val commonMain by getting { - if (hasSimplexAssets) { - resources.srcDir(simplexAssetsLocal) - } else { - resources.srcDir("src/commonMain/resources/assets/default") - } dependencies { api(compose.runtime) api(compose.foundation) @@ -78,12 +73,12 @@ kotlin { // Link Previews implementation("org.jsoup:jsoup:1.17.2") // Resources - api("dev.icerock.moko:resources:0.23.0") - api("dev.icerock.moko:resources-compose:0.23.0") + api("dev.icerock.moko:resources:0.27.1") + api("dev.icerock.moko:resources-compose:0.27.1") // Markdown - implementation("com.mikepenz:multiplatform-markdown-renderer:0.27.0") - implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.27.0") + implementation("com.mikepenz:multiplatform-markdown-renderer:0.34.0") + implementation("com.mikepenz:multiplatform-markdown-renderer-m2:0.34.0") } } val commonTest by getting { @@ -95,7 +90,6 @@ kotlin { } } val androidMain by getting { - kotlin.srcDir("build/generated/moko/androidMain/src") dependencies { implementation("androidx.activity:activity-compose:1.9.1") val workVersion = "2.9.1" @@ -158,27 +152,20 @@ kotlin { android { namespace = "chat.simplex.common" - compileSdk = 35 + compileSdk = 36 sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") defaultConfig { minSdk = 26 } - testOptions.targetSdk = 34 - lint.targetSdk = 34 - val isAndroid = gradle.startParameter.taskNames.find { - val lower = it.lowercase() - lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install") - } != null - if (isAndroid) { - // This is not needed on Android but can't be moved to desktopMain because MR lib don't support this. - // No other ways to exclude a file work, but it's large and should be excluded - kotlin.sourceSets["commonMain"].resources.exclude("/MR/fonts/NotoColorEmoji-Regular.ttf") - } + testOptions.targetSdk = 36 + lint.targetSdk = 36 } multiplatformResources { - multiplatformResourcesPackage = "chat.simplex.res" - // multiplatformResourcesClassName = "MR" + resourcesPackage.set("chat.simplex.res") + resourcesSourceSets { + getByName("commonMain").srcDirs(if (hasSimplexAssets) simplexAssetsLocal.resolve("MR") else file("src/commonMain/resources/assets/default/MR")) + } } buildConfig { @@ -286,8 +273,8 @@ afterEvaluate { } return this } - val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$") - val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree + val fileRegex = Regex("moko-resources/../strings.xml$|moko-resources/..-.../strings.xml$|moko-resources/..-../strings.xml$|moko-resources/base/strings.xml$") + val tree = fileTree("src/commonMain/moko-resources").filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found") val lvStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("lv/strings.xml") } ?: throw Exception("No base/strings.xml found") val treeList = ArrayList(tree.toList()) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt index bfb45d7413..74c9cf58a1 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt @@ -6,7 +6,7 @@ import android.provider.Settings import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* fun Context.openAppSettingsInSystem() { Intent().apply { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt index a5e105ce93..f192b4da35 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt @@ -25,6 +25,9 @@ val defaultLocale: Locale = Locale.getDefault() actual fun isAppVisibleAndFocused(): Boolean = isAppOnForeground +// the manifest declares the scheme +actual fun appLinkSchemeRegistered(): Boolean = true + @SuppressLint("StaticFieldLeak") lateinit var androidAppContext: Context var mainActivity: WeakReference = WeakReference(null) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt index d9a5fb59e3..73be3a203c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt @@ -1,11 +1,14 @@ package chat.simplex.common.platform import android.annotation.SuppressLint +import android.os.Build import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import java.security.KeyStore import javax.crypto.* import javax.crypto.spec.GCMParameterSpec @@ -68,15 +71,44 @@ internal class Cryptor: CryptorInterface { keyStore.deleteEntry(alias) } + override fun keyStorage(alias: String): String? { + val secretKey = getSecretKey(alias) ?: return null + val keyInfo = SecretKeyFactory.getInstance(secretKey.algorithm, "AndroidKeyStore").getKeySpec(secretKey, KeyInfo::class.java) as KeyInfo + val storage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + when (keyInfo.securityLevel) { + KeyProperties.SECURITY_LEVEL_STRONGBOX -> MR.strings.keystore_key_storage_strongbox + KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> MR.strings.keystore_key_storage_tee + KeyProperties.SECURITY_LEVEL_SOFTWARE -> MR.strings.keystore_key_storage_software + else -> return null + } + } else { + // isInsideSecureHardware does not distinguish StrongBox, which createSecretKey only requests on API 31+ + @Suppress("DEPRECATION") + if (keyInfo.isInsideSecureHardware) MR.strings.keystore_key_storage_tee else MR.strings.keystore_key_storage_software + } + return generalGetString(storage) + } + private fun createSecretKey(alias: String): SecretKey? { if (keyStore.containsAlias(alias)) return getSecretKey(alias) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + return generateKey(keySpec(alias).setIsStrongBoxBacked(true)) + } catch (e: StrongBoxUnavailableException) { + Log.i(TAG, "StrongBox is unavailable, using default keystore: ${e.message}") + } + } + return generateKey(keySpec(alias)) + } + + private fun keySpec(alias: String): KeyGenParameterSpec.Builder = + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + + private fun generateKey(spec: KeyGenParameterSpec.Builder): SecretKey { val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, "AndroidKeyStore") - keyGenerator.init( - KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) - .setBlockModes(BLOCK_MODE) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - ) + keyGenerator.init(spec.build()) return keyGenerator.generateKey() } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt index 1a3703822d..df2e6ccadc 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -13,6 +13,7 @@ import androidx.core.graphics.scale import boofcv.android.ConvertBitmap import boofcv.struct.image.GrayU8 import chat.simplex.common.R +import chat.simplex.common.views.helpers.decodeSampledBitmapFromByteArray import chat.simplex.common.views.helpers.errorBitmap import chat.simplex.common.views.helpers.getFileName import java.io.ByteArrayOutputStream @@ -41,6 +42,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { } } +actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? = + try { + decodeSampledBitmapFromByteArray(Base64.decode(base64, Base64.DEFAULT), width, width, MAX_IMAGE_DIMENSION).asImageBitmap() + } catch (e: Exception) { + Log.e(TAG, "decodeBase64Image error: $e") + null + } + actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { var img = image var str = compressImageStr(img) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt index 5d07aae088..a4f931058c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -2,6 +2,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.graphics.painter.Painter import java.io.File @@ -18,3 +19,5 @@ actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this actual fun Modifier.desktopPointerHoverIconHand(): Modifier = this actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = Modifier + +actual fun newPasswordContentType(): ContentType = ContentType.NewPassword + ContentType.Password diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 4f48ccca52..71d7ff3cb4 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -39,7 +39,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index e5dda23f0f..90fae139ce 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -10,7 +10,7 @@ import androidx.compose.runtime.* import chat.simplex.common.model.* import chat.simplex.common.platform.AudioPlayer.duration import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import java.io.* diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt index 79f0a8636b..6592557a7a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Share.android.kt @@ -15,7 +15,7 @@ import androidx.core.graphics.drawable.toBitmap import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.io.BufferedOutputStream import java.io.File import java.net.URI diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index f6066d1624..9dbcb6d263 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -17,7 +17,7 @@ import chat.simplex.common.AppScreen import chat.simplex.common.model.clearAndNotify import chat.simplex.common.views.helpers.* import androidx.compose.ui.platform.LocalContext as LocalContext1 -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* actual fun showToast(text: String, timeout: Long) = Toast.makeText(androidAppContext, text, Toast.LENGTH_SHORT).show() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt index 61b404f7d6..9949a21f6f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.ImageBitmap import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.android.exoplayer2.* import com.google.android.exoplayer2.C.* import com.google.android.exoplayer2.audio.AudioAttributes diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt index 2e7404f241..9e44957a2a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Type.android.kt @@ -1,15 +1,15 @@ package chat.simplex.common.ui.theme import androidx.compose.ui.text.font.* -import chat.simplex.res.MR +import chat.simplex.res.* actual val Inter: FontFamily = FontFamily( - Font(MR.fonts.Inter.regular.fontResourceId), - Font(MR.fonts.Inter.italic.fontResourceId, style = FontStyle.Italic), - Font(MR.fonts.Inter.bold.fontResourceId, FontWeight.Bold), - Font(MR.fonts.Inter.semibold.fontResourceId, FontWeight.SemiBold), - Font(MR.fonts.Inter.medium.fontResourceId, FontWeight.Medium), - Font(MR.fonts.Inter.light.fontResourceId, FontWeight.Light) + Font(MR.fonts.inter_regular.fontResourceId), + Font(MR.fonts.inter_italic.fontResourceId, style = FontStyle.Italic), + Font(MR.fonts.inter_bold.fontResourceId, FontWeight.Bold), + Font(MR.fonts.inter_semibold.fontResourceId, FontWeight.SemiBold), + Font(MR.fonts.inter_medium.fontResourceId, FontWeight.Medium), + Font(MR.fonts.inter_light.fontResourceId, FontWeight.Light) ) actual val EmojiFont: FontFamily = FontFamily.Default diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt index 0c23e95285..ff3bf11db1 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallAudioDeviceManager.kt @@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi import androidx.compose.runtime.* import chat.simplex.common.platform.* import dev.icerock.moko.resources.ImageResource -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import java.util.concurrent.Executors diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 9ad787a6b1..d4c533fd4e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -46,14 +46,13 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.* import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString import java.io.Closeable @@ -168,13 +167,24 @@ actual fun ActiveCallView() { CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates) } is WCallResponse.Connection -> - try { + // the call is restored and NOT sent to the core + // Makes sure the core DOES NOT record the duration wrong if a reconnect happens + if (r.state.connectionState == "reconnecting") { + if (call.callState != CallState.Reconnecting) { + CallSoundsPlayer.startConnectingCallSound(scope) + } + updateActiveCall(call) { it.copy(callState = CallState.Reconnecting) } + } else try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) } + if (call.callState == CallState.Reconnecting) { + CallSoundsPlayer.stop() + } + // connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting + updateActiveCall(call) { it.copy(callState = CallState.Connected, connectedAt = it.connectedAt ?: Clock.System.now()) } } withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Throwable) { @@ -705,18 +715,17 @@ fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIM val wv = webView.value if (wv != null) { LaunchedEffect(Unit) { - snapshotFlow { callCommand.firstOrNull() } - .distinctUntilChanged() - .filterNotNull() - .collect { - while (callCommand.isNotEmpty()) { - val cmd = callCommand.removeFirstOrNull() - Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}") - if (cmd != null) { - processCommand(wv, cmd) - } + while (isActive) { + // waiting for the first command would miss a command equal to the previous one + snapshotFlow { callCommand.isNotEmpty() }.first { it } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirstOrNull() + Log.d(TAG, "WebRTCView LaunchedEffect executing ${cmd?.javaClass?.simpleName}") + if (cmd != null) { + processCommand(wv, cmd) } } + } } } val assetLoader = WebViewAssetLoader.Builder() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt new file mode 100644 index 0000000000..0bb9179299 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt @@ -0,0 +1,7 @@ +package chat.simplex.common.views.chat + +import androidx.compose.runtime.* + +// Android keyboards have their own emoji panel +@Composable +actual fun ComposeEmojiButton(pickerShown: MutableState, enabled: Boolean, onEmoji: (String) -> Unit) {} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt index 719dfeea55..95438d3d0e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeView.android.kt @@ -11,7 +11,7 @@ import androidx.core.content.ContextCompat import chat.simplex.common.helpers.toURI import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.net.URI @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt index e81827cb9a..b09ef7c918 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.android.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.* import chat.simplex.common.model.CryptoFile import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.DefaultDropdownMenu -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import java.net.URI diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index b0bc6dd970..e16f99bf56 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -14,7 +14,7 @@ import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.FileChooserLauncher import chat.simplex.common.platform.saveImage import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import dev.icerock.moko.resources.compose.painterResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt index dad8872012..61d2f54318 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible import chat.simplex.common.platform.VideoPlayer import chat.simplex.common.platform.androidAppContext -import chat.simplex.res.MR +import chat.simplex.res.* import coil.ImageLoader import coil.compose.rememberAsyncImagePainter import coil.decode.GifDecoder diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt index 8c3b161a5c..256d24a48c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -18,7 +18,7 @@ import chat.simplex.common.model.durationText import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.delay import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt index ab6d375d75..a4be67473e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/TagListView.android.kt @@ -16,7 +16,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt index 1826b2114e..a81d9cd1ee 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.SimplexGreen import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -59,6 +59,7 @@ actual fun DatabaseEncryptionFooter( useKeychain: MutableState, chatDbEncrypted: Boolean?, storedKey: MutableState, + keyStorage: String?, initialRandomDBPassphrase: MutableState, migration: Boolean, ) { @@ -66,7 +67,7 @@ actual fun DatabaseEncryptionFooter( SectionTextFooter(generalGetString(MR.strings.database_is_not_encrypted)) } else if (useKeychain.value) { if (storedKey.value) { - SectionTextFooter(generalGetString(MR.strings.keychain_is_storing_securely)) + SectionTextFooter(String.format(generalGetString(MR.strings.keychain_is_storing_securely), keyStorage?.let { " ($it)" } ?: "")) if (initialRandomDBPassphrase.value && !migration) { SectionTextFooter(generalGetString(MR.strings.encrypted_with_random_passphrase)) } else { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt index 3dace5f9bc..4c980acf91 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import chat.simplex.common.views.newchat.ActionButton -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt index 1c7ba1dcf0..d499b7ab3f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/GetImageView.android.kt @@ -33,7 +33,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.model.json import chat.simplex.common.platform.* import chat.simplex.common.views.newchat.ActionButton -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.serialization.builtins.* import java.io.File import java.net.URI diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index de6834ba41..7b2f53d365 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -24,7 +24,7 @@ import androidx.core.text.HtmlCompat import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import java.io.* import java.net.URI @@ -194,11 +194,12 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair? } // https://developer.android.com/topic/performance/graphics/load-bitmap#load-bitmap -private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int): Bitmap { +internal fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, reqHeight: Int, maxDimension: Int = Int.MAX_VALUE): Bitmap { // First decode with inJustDecodeBounds=true to check dimensions return BitmapFactory.Options().run { inJustDecodeBounds = true BitmapFactory.decodeByteArray(data, 0, data.size, this) + if (outWidth > maxDimension || outHeight > maxDimension) throw IOException("Image dimensions exceed limit") // Calculate inSampleSize inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) // Decode bitmap with inSampleSize set diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt index 6cf432a15f..416bd359c0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/QRCodeScanner.android.kt @@ -27,7 +27,7 @@ import boofcv.struct.image.GrayU8 import chat.simplex.common.platform.TAG import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState import com.google.common.util.concurrent.ListenableFuture diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt index a4fc74f6d4..0e1cf9472a 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import chat.simplex.common.model.SharedPreference import chat.simplex.common.model.User -import chat.simplex.res.MR +import chat.simplex.res.* @Composable actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)?) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt index c16d1ea90d..e5ae2b8a9b 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt @@ -32,7 +32,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.helpers.APPLICATION_ID import chat.simplex.common.helpers.saveAppLocale import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt index 3c1771c8b3..230dee8851 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.android.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity import chat.simplex.common.model.ChatModel -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt index 5e9706d713..4da9bf03b0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.jakewharton.processphoenix.ProcessPhoenix import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index c0497daacb..f66fd82473 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -4,6 +4,9 @@ //#include //#include +// from libc (stdlib.h is not included because of the reallocarray stub below) +void free(void *ptr); + // from the RTS void hs_init_with_rtsopts(int * argc, char **argv[]); @@ -49,6 +52,13 @@ Java_chat_simplex_common_platform_CoreKt_initHS(__unused JNIEnv *env, __unused j // from simplex-chat typedef long* chat_ctrl; +// Strings returned by chat_* functions are allocated with malloc and owned by the caller. +static jstring decode_and_free(JNIEnv *env, char *string) { + jstring res = (*env)->NewStringUTF(env, string); + free(string); + return res; +} + /* When you start using any new function from Haskell libraries, you have to add the function name to the file libsimplex.dll.def in the root directory. @@ -80,7 +90,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE); const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE); jlong _ctrl = (jlong) 0; - jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); + jstring res = decode_and_free(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey); (*env)->ReleaseStringUTFChars(env, confirm, _confirm); @@ -99,7 +109,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatCloseStore(JNIEnv *env, __unused jclass clazz, jlong controller) { - jstring res = (*env)->NewStringUTF(env, chat_close_store((void*)controller)); + jstring res = decode_and_free(env, chat_close_store((void*)controller)); return res; } @@ -109,7 +119,7 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmdRetry(JNIEnv *env, __unused //jint length = (jint) (*env)->GetStringUTFLength(env, msg); //for (int i = 0; i < length; ++i) // __android_log_print(ANDROID_LOG_ERROR, "simplex", "%d: %02x\n", i, _msg[i]); - jstring res = (*env)->NewStringUTF(env, chat_send_cmd_retry((void*)controller, _msg, retryNum)); + jstring res = decode_and_free(env, chat_send_cmd_retry((void*)controller, _msg, retryNum)); (*env)->ReleaseStringUTFChars(env, msg, _msg); return res; } @@ -117,25 +127,25 @@ Java_chat_simplex_common_platform_CoreKt_chatSendCmdRetry(JNIEnv *env, __unused JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatSendRemoteCmdRetry(JNIEnv *env, __unused jclass clazz, jlong controller, jint rhId, jstring msg, jint retryNum) { const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_send_remote_cmd_retry((void*)controller, rhId, _msg, retryNum)); + jstring res = decode_and_free(env, chat_send_remote_cmd_retry((void*)controller, rhId, _msg, retryNum)); (*env)->ReleaseStringUTFChars(env, msg, _msg); return res; } JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) { - return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller)); + return decode_and_free(env, chat_recv_msg((void*)controller)); } JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatRecvMsgWait(JNIEnv *env, __unused jclass clazz, jlong controller, jint wait) { - return (*env)->NewStringUTF(env, chat_recv_msg_wait((void*)controller, wait)); + return decode_and_free(env, chat_recv_msg_wait((void*)controller, wait)); } JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_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)); + jstring res = decode_and_free(env, chat_parse_markdown(_str)); (*env)->ReleaseStringUTFChars(env, str, _str); return res; } @@ -143,7 +153,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseMarkdown(JNIEnv *env, __unused JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str)); + jstring res = decode_and_free(env, chat_parse_server(_str)); (*env)->ReleaseStringUTFChars(env, str, _str); return res; } @@ -151,7 +161,7 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused j JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, __unused jclass clazz, jstring str, jint safe) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_uri(_str, safe)); + jstring res = decode_and_free(env, chat_parse_uri(_str, safe)); (*env)->ReleaseStringUTFChars(env, str, _str); return res; } @@ -160,7 +170,7 @@ JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) { const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE); const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt)); + jstring res = decode_and_free(env, chat_password_hash(_pwd, _salt)); (*env)->ReleaseStringUTFChars(env, pwd, _pwd); (*env)->ReleaseStringUTFChars(env, salt, _salt); return res; @@ -169,7 +179,7 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) { const char *_name = (*env)->GetStringUTFChars(env, name, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_valid_name(_name)); + jstring res = decode_and_free(env, chat_valid_name(_name)); (*env)->ReleaseStringUTFChars(env, name, _name); return res; } @@ -177,7 +187,7 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatParseBadgeCode(JNIEnv *env, jclass clazz, jstring code) { const char *_code = (*env)->GetStringUTFChars(env, code, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_parse_badge_code(_code)); + jstring res = decode_and_free(env, chat_parse_badge_code(_code)); (*env)->ReleaseStringUTFChars(env, code, _code); return res; } @@ -195,7 +205,7 @@ Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); jbyte *buff = (jbyte *) (*env)->GetDirectBufferAddress(env, buffer); jlong capacity = (*env)->GetDirectBufferCapacity(env, buffer); - jstring res = (*env)->NewStringUTF(env, chat_write_file((void*)controller, _path, buff, capacity)); + jstring res = decode_and_free(env, chat_write_file((void*)controller, _path, buff, capacity)); (*env)->ReleaseStringUTFChars(env, path, _path); return res; } @@ -229,6 +239,7 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, arr = (*env)->NewByteArray(env, len); (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1); } + free(res); jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"), @@ -243,7 +254,7 @@ JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatEncryptFile(JNIEnv *env, jclass clazz, jlong controller, jstring from_path, jstring to_path) { const char *_from_path = (*env)->GetStringUTFChars(env, from_path, JNI_FALSE); const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_encrypt_file((void*)controller, _from_path, _to_path)); + jstring res = decode_and_free(env, chat_encrypt_file((void*)controller, _from_path, _to_path)); (*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, to_path, _to_path); return res; @@ -255,7 +266,7 @@ Java_chat_simplex_common_platform_CoreKt_chatDecryptFile(JNIEnv *env, jclass cla const char *_key = (*env)->GetStringUTFChars(env, key, JNI_FALSE); const char *_nonce = (*env)->GetStringUTFChars(env, nonce, JNI_FALSE); const char *_to_path = (*env)->GetStringUTFChars(env, to_path, JNI_FALSE); - jstring res = (*env)->NewStringUTF(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path)); + jstring res = decode_and_free(env, chat_decrypt_file(_from_path, _key, _nonce, _to_path)); (*env)->ReleaseStringUTFChars(env, from_path, _from_path); (*env)->ReleaseStringUTFChars(env, key, _key); (*env)->ReleaseStringUTFChars(env, nonce, _nonce); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 6bb6ffbe97..fddeb16204 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -64,6 +64,7 @@ jstring decode_to_utf8_string(JNIEnv *env, char *string) { (*env)->DeleteLocalRef(env, bb); (*env)->DeleteLocalRef(env, charset); (*env)->DeleteLocalRef(env, cb); + free(string); return res; } @@ -239,6 +240,7 @@ Java_chat_simplex_common_platform_CoreKt_chatReadFile(JNIEnv *env, jclass clazz, arr = (*env)->NewByteArray(env, len); (*env)->SetByteArrayRegion(env, arr, 0, len, res + 1); } + free(res); jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); jobject statusObj = (*env)->NewObject(env, (*env)->FindClass(env, "java/lang/Integer"), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 338e602a45..ce1152de6a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -37,7 +37,7 @@ import chat.simplex.common.views.localauth.VerticalDivider import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* @@ -207,7 +207,7 @@ fun MainScreen() { SwitchingUsersView() } - if (unauthorized.value && !(chatModel.activeCallViewIsVisible.value && chatModel.showCallView.value)) { + if (unauthorized.value) { LaunchedEffect(Unit) { // With these constrains when user presses back button while on ChatList, activity destroys and shows auth request // while the screen moves to a launcher. Detect it and prevent showing the auth @@ -221,7 +221,8 @@ fun MainScreen() { SplashView(true) ModalManager.fullscreen.showPasscodeInView() } - } else { + } + if (!unauthorized.value || chatModel.activeCallViewIsVisible.value) { if (chatModel.showCallView.value) { if (appPlatform.isAndroid) { LaunchedEffect(Unit) { @@ -235,6 +236,8 @@ fun MainScreen() { ActiveCallView() } } + } + if (!unauthorized.value) { ModalManager.fullscreen.showOneTimePasscodeInView() AlertManager.privacySensitive.showInView() if (onboarding == OnboardingStage.OnboardingComplete) { @@ -412,7 +415,10 @@ fun CenterPartOfScreen() { } } } - else -> ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} + else -> { + ChatView(chatsCtx = chatModel.chatsContext, currentChatId) {} + ModalManager.center.showInView() + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index f464a4c20a..9b42792e8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -10,7 +10,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.localauth.SetAppPasscodeView import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* // Spec: spec/client/navigation.md#AppLock @@ -269,7 +269,11 @@ object AppLock { fun elapsedRealtime(): Long = System.nanoTime() / 1_000_000 fun recheckAuthState() { - if (ChatModel.showCallView.value) return + if (ChatModel.showCallView.value) { + // BiometricPrompt drops a prompt requested while MainActivity is stopped (call on lock screen), so request it again + if (userAuthorized.value == false) runAuthenticate() + return + } val enteredBackgroundVal = enteredBackground.value val delay = ChatController.appPrefs.laLockDelay.get() if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 3828b5e5f6..5cbd95df36 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrationToDeviceState import chat.simplex.common.views.migration.MigrationToState -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -132,6 +132,8 @@ object BadgeModel { enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake } +data class FileToOpen(val fileId: Long, val inChatList: Boolean) + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ @@ -205,8 +207,21 @@ object ChatModel { // another in its place; only the badge alert shows regardless. Set while rendering, so not a state. var chatListBanner: ChatListBanner? = null + var fileToOpen: FileToOpen? = null + fun bannerSlotFree(banner: ChatListBanner): Boolean = chatListBanner == null || chatListBanner == banner + fun openWhenReceived(file: CIFile, inChatList: Boolean) { + fileToOpen = FileToOpen(file.fileId, inChatList) + } + + fun receivedFileToOpen(file: CIFile, inChatList: Boolean): Boolean { + val f = fileToOpen + if (f == null || f.fileId != file.fileId || f.inChatList != inChatList || !file.loaded) return false + fileToOpen = null + return !inChatList || appPlatform.isDesktop || chatId.value == null + } + // preferences val notificationPreviewMode by lazy { mutableStateOf( @@ -1130,7 +1145,7 @@ object ChatModel { fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) { val current = currentUser.value ?: return val updated = current.copy( - profile = newProfile.toLocalProfile(current.profile.profileId), + profile = newProfile.toLocalProfile(current.profile.profileId).copy(localBadge = current.profile.localBadge, contactDomainVerified = current.profile.contactDomainVerified), fullPreferences = preferences ?: current.fullPreferences ) val i = users.indexOfFirst { it.user.userId == current.userId && it.user.remoteHostId == rhId } @@ -1954,7 +1969,7 @@ data class Contact( activeConn?.customUserProfileId != null val chatIconName: ImageResource get() = - if (isBot) MR.images.ic_cube else MR.images.ic_account_circle_filled + peerTypeIcon(profile.peerType) fun allowsFeature(feature: ChatFeature): Boolean = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.contactPreference.allow != FeatureAllowed.NO @@ -2165,6 +2180,7 @@ sealed class ChatPeerType { @Serializable @SerialName("human") object Human: ChatPeerType() @Serializable @SerialName("bot") object Bot: ChatPeerType() @Serializable @SerialName("business") object Business: ChatPeerType() + @Serializable @SerialName("agent") object Agent: ChatPeerType() @Serializable @SerialName("unknown") data class Unknown(val type: String): ChatPeerType() val text: String @@ -2172,6 +2188,7 @@ sealed class ChatPeerType { is Human -> "human" is Bot -> "bot" is Business -> "business" + is Agent -> "agent" is Unknown -> type } } @@ -2183,11 +2200,19 @@ object ChatPeerTypeSerializer : KSerializer { "human" -> ChatPeerType.Human "bot" -> ChatPeerType.Bot "business" -> ChatPeerType.Business + "agent" -> ChatPeerType.Agent else -> ChatPeerType.Unknown(v) } override fun serialize(encoder: Encoder, value: ChatPeerType) = encoder.encodeString(value.text) } +fun peerTypeIcon(peerType: ChatPeerType?): ImageResource = + when (peerType) { + ChatPeerType.Bot -> MR.images.ic_cube + ChatPeerType.Agent -> MR.images.ic_agent + else -> MR.images.ic_account_circle_filled + } + // Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status. // Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter. @Serializable(with = BadgeTypeSerializer::class) @@ -2286,6 +2311,12 @@ sealed class BadgeIssueFailure { is Unexpected -> String.format(generalGetString(MR.strings.badges_error_unexpected), message) } + val purchaseText: String get() = when (this) { + is ServiceError -> badgeServiceErrorText(code) ?: String.format(generalGetString(MR.strings.badges_error_purchase_refused), code.text) + is InvalidCredential -> generalGetString(MR.strings.badges_error_credential_not_verified) + is ServiceTimeout, is Network, is Unexpected -> text + } + // the stored form, for support val tag: String get() = when (this) { is ServiceError -> "serviceError ${if (retryable) "retry" else "final"} ${code.text}" @@ -3391,6 +3422,7 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, + val markdownBlocks: List? = null, val mentions: Map? = null, val quotedItem: CIQuote? = null, val reactions: List, @@ -4396,7 +4428,8 @@ class CIQuote ( val sharedMsgId: String? = null, val sentAt: Instant, val content: MsgContent, - val formattedText: List? = null + val formattedText: List? = null, + val markdownBlocks: List? = null ): ItemContent { override val text: String by lazy { if (content.text == "" && content is MsgContent.MCVoice) @@ -5148,7 +5181,7 @@ class FormattedText(val text: String, val format: Format? = null) { val linkUri: String? get() = when (format) { is Format.Uri -> text - is Format.HyperLink -> format.linkUri + is Format.HyperLink -> if (format.isEmail) null else format.linkUri else -> null } @@ -5159,7 +5192,7 @@ class FormattedText(val text: String, val format: Format? = null) { @Serializable sealed class Format { - @Serializable @SerialName("bold") class Bold: Format() + @Serializable @SerialName("bold") class Bold(val singleStar: Boolean = false): Format() @Serializable @SerialName("italic") class Italic: Format() @Serializable @SerialName("strikeThrough") class StrikeThrough: Format() @Serializable @SerialName("snippet") class Snippet: Format() @@ -5167,7 +5200,9 @@ sealed class Format { @Serializable @SerialName("small") class Small: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() - @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() + @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() { + val isEmail: Boolean get() = linkUri.startsWith("mailto:") + } @Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() { val simplexLinkText: String get() = "${linkType.description} $viaHosts" @@ -5184,6 +5219,8 @@ sealed class Format { } @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() + @Serializable @SerialName("escape") class Escape: Format() + @Serializable @SerialName("markup") class Markup(val token: BlockToken): Format() @Serializable @SerialName("unknown") class Unknown: Format() val style: SpanStyle @Composable get() = when (this) { @@ -5203,6 +5240,8 @@ sealed class Format { is Email -> linkStyle is Phone -> linkStyle is Modal -> linkStyle + is Escape -> SpanStyle() + is Markup -> SpanStyle() is Unknown -> SpanStyle() } @@ -5213,6 +5252,87 @@ sealed class Format { } } +@Serializable +sealed class BlockToken { + @Serializable @SerialName("lineBreak") class LineBreak: BlockToken() + @Serializable @SerialName("softBreak") class SoftBreak: BlockToken() + @Serializable @SerialName("hardBreak") class HardBreak: BlockToken() + @Serializable @SerialName("paragraphBreak") class ParagraphBreak(val lineBreaks: Int): BlockToken() + @Serializable @SerialName("indent") class Indent: BlockToken() + @Serializable @SerialName("headingMark") class HeadingMark(val level: Int): BlockToken() + @Serializable @SerialName("headingClose") class HeadingClose: BlockToken() + @Serializable @SerialName("headingUnderline") class HeadingUnderline(val level: Int): BlockToken() + @Serializable @SerialName("listMark") class ListMark(val number: String? = null): BlockToken() + @Serializable @SerialName("quoteMark") class QuoteMark: BlockToken() + @Serializable @SerialName("fenceMark") class FenceMark(val language: String? = null): BlockToken() + @Serializable @SerialName("rowMark") class RowMark: BlockToken() + @Serializable @SerialName("cellMark") class CellMark: BlockToken() + @Serializable @SerialName("delimiterRow") class DelimiterRow(val columns: List): BlockToken() + @Serializable @SerialName("ruleMark") class RuleMark: BlockToken() + + val drawnText: String get() = when (this) { + is SoftBreak -> " " + is LineBreak, is HardBreak -> "\n" + is ParagraphBreak -> "\n".repeat(lineBreaks) + else -> "" + } + + val previewText: String get() = when (this) { + is SoftBreak, is HardBreak, is CellMark -> " " + is LineBreak, is ParagraphBreak -> "\n" + is ListMark -> (number ?: "•") + " " + else -> "" + } +} + +@Serializable +sealed class MarkdownBlock { + @Serializable @SerialName("paragraph") class Paragraph(val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("heading") class Heading(val level: Int, val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("codeBlock") class CodeBlock(val language: String? = null, val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("quote") class Quote(val content: SegmentRange): MarkdownBlock() + @Serializable @SerialName("list") class ItemList(val items: List): MarkdownBlock() + @Serializable @SerialName("table") class Table(val columns: List, val header: List, val rows: List>): MarkdownBlock() + @Serializable @SerialName("rule") class Rule(val mark: SegmentRange): MarkdownBlock() +} + +@Serializable +class ListEntry(val number: String? = null, val content: SegmentRange, val items: List) + +@Serializable +class SegmentRange(val from: Int, val to: Int) + +@Serializable +enum class TableAlign { + @SerialName("none") None, + @SerialName("left") Left, + @SerialName("center") Center, + @SerialName("right") Right +} + +class BlockTextRange(val range: SegmentRange, val inRow: Boolean) + +val MarkdownBlock.textRanges: List get() = when (this) { + is MarkdownBlock.Paragraph -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.Heading -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.CodeBlock -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.Quote -> listOf(BlockTextRange(content, false)) + is MarkdownBlock.ItemList -> listEntries(items, 0).map { (entry, _) -> BlockTextRange(entry.content, false) } + is MarkdownBlock.Table -> (listOf(header) + rows).flatMap { row -> row.mapIndexed { c, cell -> BlockTextRange(cell, c > 0) } } + is MarkdownBlock.Rule -> emptyList() +} + +fun listEntries(items: List, level: Int): List> = + items.flatMap { listOf(it to level) + listEntries(it.items, level + 1) } + +fun blockTextRanges(blocks: List): List = blocks.flatMap { it.textRanges } + +fun headingSegments(size: Int, blocks: List): BooleanArray { + val bold = BooleanArray(size) + blocks.forEach { if (it is MarkdownBlock.Heading) for (i in it.content.from.coerceIn(0, size) until it.content.to.coerceIn(0, size)) bold[i] = true } + return bold +} + @Serializable enum class SimplexLinkType(val linkType: String) { contact("contact"), @@ -5698,6 +5818,7 @@ data class ChatItemVersion( val chatItemVersionId: Long, val msgContent: MsgContent, val formattedText: List?, + val markdownBlocks: List? = null, val itemVersionTs: Instant, val createdAt: Instant, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index dace96803a..b509c21ed3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -27,11 +27,14 @@ import chat.simplex.common.model.SMPProxyMode.Always import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.BadgeStore +import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chat.item.showContentBlockedAlert import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat +import chat.simplex.common.views.database.deleteDatabaseBackups import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* @@ -39,7 +42,7 @@ import chat.simplex.common.views.usersettings.networkAndServers.defaultCondition import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration -import chat.simplex.res.MR +import chat.simplex.res.* import com.russhwolf.settings.Settings import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -141,6 +144,7 @@ class AppPreferences { val appearanceBarsBlurRadius = mkIntPreference(SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS, if (deviceSupportsBlur) 50 else 0) val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false) val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false) + val markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, 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) @@ -215,6 +219,7 @@ class AppPreferences { val encryptedSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE, null) val initializationVectorSelfDestructPassphrase = mkStrPreference(SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE, null) val encryptionStartedAt = mkDatePreference(SHARED_PREFS_ENCRYPTION_STARTED_AT, null) + val shouldDeleteDatabaseBackups = mkBoolPreference(SHARED_PREFS_SHOULD_DELETE_DATABASE_BACKUPS, false) val confirmDBUpgrades = mkBoolPreference(SHARED_PREFS_CONFIRM_DB_UPGRADES, false) val selfDestruct = mkBoolPreference(SHARED_PREFS_SELF_DESTRUCT, false) val selfDestructDisplayName = mkStrPreference(SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME, null) @@ -427,6 +432,7 @@ class AppPreferences { const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup" private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls" private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites" + private const val SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW = "MarkdownFileTextView" private const val SHARED_PREFS_CHAT_ARCHIVE_NAME = "ChatArchiveName" private const val SHARED_PREFS_CHAT_ARCHIVE_TIME = "ChatArchiveTime" private const val SHARED_PREFS_APP_LANGUAGE = "AppLanguage" @@ -487,6 +493,7 @@ class AppPreferences { private const val SHARED_PREFS_ENCRYPTED_SELF_DESTRUCT_PASSPHRASE = "EncryptedSelfDestructPassphrase" private const val SHARED_PREFS_INITIALIZATION_VECTOR_SELF_DESTRUCT_PASSPHRASE = "InitializationVectorSelfDestructPassphrase" private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt" + private const val SHARED_PREFS_SHOULD_DELETE_DATABASE_BACKUPS = "ShouldDeleteDatabaseBackups" private const val SHARED_PREFS_NEW_DATABASE_INITIALIZED = "NewDatabaseInitialized" private const val SHARED_PREFS_SHOULD_IMPORT_APP_SETTINGS = "ShouldImportAppSettings" private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades" @@ -587,13 +594,38 @@ object ChatController { } } + // log = false because a store receipt is a bearer secret, like a badge code - it is in the command. + suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment): BadgePurchaseResult { + val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment), log = false) + return when { + r is API.Result && r.res is CR.BadgeStateR -> BadgePurchaseResult.Held(r.res.user, r.res.badgeState, r.res.storePurchases) + r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Credited(r.res.user.updateRemoteHostId(rh), r.res.badgeState) + r is API.Error -> BadgePurchaseResult.Failed(r.err) + else -> { + // the response type alone - it names a case or a JSON key, never the service's message + Log.e(TAG, "apiPurchaseBadge: unexpected ${r.responseType}") + BadgePurchaseResult.Failed(null) + } + } + } + + suspend fun apiCreateBadgeInvoice(rh: Long?, userId: Long): String { + val r = sendCmd(rh, CC.ApiCreateBadgeInvoice(userId)) + if (r is API.Result && r.res is CR.BadgeInvoice) return r.res.invoiceId + if (r is API.Error) throw BadgeStoreError.ApiError(r.err) + throw Exception("apiCreateBadgeInvoice: unexpected ${r.responseType}") + } + // localized where the user can act on it; otherwise the error itself, so a screenshot says what happened - fun redeemErrorText(err: ChatError?): String { + fun redeemErrorText(err: ChatError?, purchase: Boolean): String { if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.CEBadgeRedeemError) { when (val e = err.errorType.badgeRedeemError) { is BadgeRedeemError.InvalidCode -> return generalGetString(MR.strings.badges_error_invalid_code) - is BadgeRedeemError.ServiceNotConfigured -> return generalGetString(MR.strings.badges_error_service_not_configured) - is BadgeRedeemError.BadgeActive -> return generalGetString(MR.strings.badges_error_already_active) + is BadgeRedeemError.InvalidReceipt -> {} + is BadgeRedeemError.ServiceNotConfigured -> + return generalGetString(if (purchase) MR.strings.badges_error_service_not_configured_purchase else MR.strings.badges_error_service_not_configured) + is BadgeRedeemError.BadgeActive -> + return generalGetString(if (purchase) MR.strings.badges_error_already_active_purchase else MR.strings.badges_error_already_active) is BadgeRedeemError.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it } is BadgeRedeemError.InvalidResponse -> return String.format(generalGetString(MR.strings.badges_error_bad_service_response), e.message) is BadgeRedeemError.UnknownKeyIndex, is BadgeRedeemError.CredentialNotVerified -> return generalGetString(MR.strings.badges_error_credential_not_verified) @@ -602,9 +634,9 @@ object ChatController { return "${generalGetString(MR.strings.error_prefix)}: ${err?.string ?: generalGetString(MR.strings.badges_error_unknown)}" } - suspend fun apiGetBadgeState(rh: Long?, userId: Long): BadgeState? { + suspend fun apiGetBadgeState(rh: Long?, userId: Long): Pair> { val r = sendCmd(rh, CC.ApiGetBadgeState(userId)) - if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState + if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState to r.res.storePurchases throw Exception("apiGetBadgeState: unexpected ${r.responseType}") } @@ -637,11 +669,14 @@ object ChatController { // Not thrown: a failed badge read must not stop the app starting, and the model is left alone // rather than set to nil, which would read as "no badge". - private suspend fun loadBadgeState(rhId: Long?) { + suspend fun loadBadgeState(rhId: Long?) { try { val userId = currentUserId("loadBadgeState") - val badgeState = apiGetBadgeState(rhId, userId) - withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) } + val (badgeState, storePurchases) = apiGetBadgeState(rhId, userId) + withContext(Dispatchers.Main) { + BadgeModel.set(rhId, userId, badgeState) + BadgeStore.setStorePurchases(rhId, userId, storePurchases) + } } catch (e: Exception) { Log.e(TAG, "loadBadgeState: ${e.message}") } @@ -686,6 +721,11 @@ object ChatController { } apiStartChat() appPrefs.chatStopped.set(false) + withLongRunningApi { BadgeStore.presentUnfinished() } + if (appPrefs.shouldDeleteDatabaseBackups.get()) { + deleteDatabaseBackups() + appPrefs.shouldDeleteDatabaseBackups.set(false) + } } catch (e: Throwable) { Log.e(TAG, "failed starting chat $e") throw e @@ -751,6 +791,7 @@ object ChatController { chatModel.users.clear() chatModel.users.addAll(users) getUserChatData(rhId, keepingChatId = keepingChatId) + withLongRunningApi { BadgeStore.presentUnfinished() } val invitation = chatModel.callInvitations.values.firstOrNull { inv -> inv.user.userId == toUserId } if (invitation != null && currentUser != null) { chatModel.callManager.reportNewIncomingCall(invitation.copy(user = currentUser)) @@ -1923,8 +1964,10 @@ object ChatController { val r = sendCmd(rh, CC.ApiUpdateProfile(userId, profile)) if (r is API.Result && r.res is CR.UserProfileNoChange) return profile to emptyList() if (r is API.Result && r.res is CR.UserProfileUpdated) return r.res.toProfile to r.res.updateSummary.changedContacts - if (r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName) { + if (r is API.Error && (r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.DuplicateName || r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.UserExists)) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc)) + } else if (!(networkErrorAlert(r))) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_profile), "${r.responseType}: ${r.details}") } Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") return null @@ -1936,7 +1979,12 @@ object ChatController { return when { r is API.Result && r.res is CR.UserProfileNoChange -> null r is API.Result && r.res is CR.UserProfileUpdated -> r.res.user.updateRemoteHostId(rh) - else -> throw Exception("failed to set profile address: ${r.responseType} ${r.details}") + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiSetProfileAddress", generalGetString(MR.strings.error_saving_profile), r) + } + throw Exception("failed to set profile address: ${r.responseType} ${r.details}") + } } } @@ -2140,8 +2188,8 @@ object ChatController { return r.result is CR.CmdOk } - suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcIceCandidates: String): Boolean { - val extraInfo = WebRTCExtraInfo(rtcIceCandidates) + suspend fun apiSendCallExtraInfo(rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String): Boolean { + val extraInfo = WebRTCExtraInfo(rtcSession, rtcIceCandidates) val r = sendCmd(rh, CC.ApiSendCallExtraInfo(contact, extraInfo)) return r.result is CR.CmdOk } @@ -3119,6 +3167,13 @@ object ChatController { // TODO NtfManager.shared.notifyGroupInvitation } } + is CR.ReceivedGroupInvitationViaGroup -> { + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + } + } + } is CR.UserAcceptedGroupSent -> { if (!active(r.user)) return @@ -3369,20 +3424,13 @@ object ChatController { chatModel.callManager.reportNewIncomingCall(r.callInvitation.copy(remoteHostId = rhId)) } is CR.CallOffer -> { - // TODO askConfirmation? - // TODO check encryption is compatible withCall(r, r.contact) { call -> chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, hasSharedKey = r.sharedKey != null) - val useRelay = appPrefs.webrtcPolicyRelay.get() - val iceServers = getIceServers() - chatModel.callCommand.add(WCallCommand.Offer( - offer = r.offer.rtcSession, - iceCandidates = r.offer.rtcIceCandidates, - media = r.callType.media, - aesKey = r.sharedKey, - iceServers = iceServers, - relay = useRelay - )) + if (r.askConfirmation) { + showUnencryptedCallAlert(call) { processCallOffer(r) } + } else { + processCallOffer(r) + } } } is CR.CallAnswer -> { @@ -3393,7 +3441,7 @@ object ChatController { } is CR.CallExtraInfo -> { withCall(r, r.contact) { _ -> - chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)) + chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates, session = r.extraInfo.rtcSession)) } } is CR.CallEnded -> { @@ -3585,6 +3633,10 @@ object ChatController { BadgeModel.setAlert(rhId, r.user.userId, r.badgeAlert) } } + is CR.StorePurchaseCredited -> + withLongRunningApi { BadgeStore.storePurchaseCredited(rhId, r.user, r.invoiceId) } + is CR.StorePurchaseRefused -> + withLongRunningApi { BadgeStore.storePurchaseRefused(rhId, r.user, r.invoiceId, r.refusal) } else -> Log.d(TAG , "unsupported event: ${msg.responseType}") } @@ -3626,7 +3678,7 @@ object ChatController { getUserChatData(null) } - private fun activeUser(rhId: Long?, user: UserLike): Boolean = + fun activeUser(rhId: Long?, user: UserLike): Boolean = rhId == chatModel.remoteHostId() && user.userId == chatModel.currentUser.value?.userId private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { @@ -3638,6 +3690,39 @@ object ChatController { } } + private fun processCallOffer(r: CR.CallOffer) { + val useRelay = appPrefs.webrtcPolicyRelay.get() + val iceServers = getIceServers() + chatModel.callCommand.add(WCallCommand.Offer( + offer = r.offer.rtcSession, + iceCandidates = r.offer.rtcIceCandidates, + media = r.callType.media, + aesKey = r.sharedKey, + iceServers = iceServers, + relay = useRelay + )) + } + + private fun showUnencryptedCallAlert(call: Call, onContinue: () -> Unit) { + // the call may have ended, or a new one started with the same contact, while the alert was shown + fun offerPending(): Boolean { + val c = chatModel.activeCall.value + return c != null && c.remoteHostId == call.remoteHostId && c.contact.id == call.contact.id && c.callState == CallState.OfferReceived + } + val endCall = { if (offerPending()) withBGApi { chatModel.callManager.endCall(call) } } + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.call_not_encrypted_title), + text = generalGetString(MR.strings.call_not_encrypted_desc).format(call.contact.displayName), + confirmText = generalGetString(MR.strings.call_service_notification_end_call), + onConfirm = { endCall() }, + dismissText = generalGetString(MR.strings.continue_to_next_step), + onDismiss = { if (offerPending()) onContinue() }, + onDismissRequest = { endCall() }, + destructive = true, + parseHtml = false + ) + } + suspend fun leaveGroup(rh: Long?, groupId: Long) { val groupInfo = apiLeaveGroup(rh, groupId) if (groupInfo != null) { @@ -3888,6 +3973,17 @@ sealed class BadgeRedeemResult { class Failed(val err: ChatError?): BadgeRedeemResult() } +@Serializable +data class OpenStorePurchase(val invoiceId: String? = null, val transactionRef: String? = null, val creditError: BadgeIssueFailure? = null) + +// a refusal is Failed, with the code in BREServiceError +sealed class BadgePurchaseResult { + class Held(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List): BadgePurchaseResult() + class Credited(val user: User, val badgeState: BadgeState?): BadgePurchaseResult() + // err is null for a response of an unexpected type, which is logged where it is received + class Failed(val err: ChatError?): BadgePurchaseResult() +} + // ChatCommand // Spec: spec/api.md#CC sealed class CC { @@ -4068,6 +4164,8 @@ sealed class CC { class ApiStandaloneFileInfo(val url: String): CC() // badges class ApiRedeemBadgeCode(val userId: Long, val code: String): CC() + class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val payment: ServicePayment): CC() + class ApiCreateBadgeInvoice(val userId: Long): CC() class ApiGetBadgeState(val userId: Long): CC() class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC() class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC() @@ -4293,6 +4391,8 @@ sealed class CC { is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}" is ApiStandaloneFileInfo -> "/_download info $url" is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code" + is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""} ${json.encodeToString(payment)}" + is ApiCreateBadgeInvoice -> "/_badge invoice $userId" is ApiGetBadgeState -> "/_badge state $userId" is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId" is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode" @@ -4477,6 +4577,8 @@ sealed class CC { is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile" is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" + is ApiPurchaseBadge -> "apiPurchaseBadge" + is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice" is ApiGetBadgeState -> "apiGetBadgeState" is ApiGetBadgeLedger -> "apiGetBadgeLedger" is ApiAckBadgeAlert -> "apiAckBadgeAlert" @@ -4501,8 +4603,11 @@ sealed class CC { is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd)) is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd)) is TestStorageEncryption -> TestStorageEncryption(obfuscate(key)) - // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + // a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code)) + is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, when (payment) { + is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token)) + }) else -> this } @@ -4536,6 +4641,12 @@ sealed class CC { } } +// the field must stay typed as the sealed base, or kotlinx omits the "type" tag core parses by +@Serializable +sealed class ServicePayment { + @Serializable @SerialName("google") class Google(val productId: String, val token: String): ServicePayment() +} + fun onOff(b: Boolean): String = if (b) "on" else "off" // /_badge ack takes the kind in core's text encoding, not the JSON tag @@ -4858,6 +4969,7 @@ sealed class UserServersError { @Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError() + @Serializable @SerialName("tooManyHosts") data class TooManyHosts(val protocol: ServerProtocol, val tooManyHostsServer: String): UserServersError() @Serializable @SerialName("duplicateChatRelayAddress") data class DuplicateChatRelayAddress(val duplicateChatRelay: String, val duplicateAddress: String): UserServersError() val globalError: String? @@ -4873,6 +4985,7 @@ sealed class UserServersError { is StorageMissing -> this.protocol is ProxyMissing -> this.protocol is DuplicateServer -> this.protocol + is TooManyHosts -> this.protocol is DuplicateChatRelayAddress -> null } @@ -4888,6 +5001,8 @@ sealed class UserServersError { is ProxyMissing -> this.user?.let { "${userStr(it)} ${generalGetString(MR.strings.no_message_servers_configured_for_private_routing)}" } ?: generalGetString(MR.strings.no_message_servers_configured_for_private_routing) + is TooManyHosts -> String.format(generalGetString(MR.strings.server_has_too_many_hosts), ServerAddress.parseServerAddress(this.tooManyHostsServer)?.hostnames?.firstOrNull() ?: this.tooManyHostsServer) + else -> null } } else { @@ -6776,6 +6891,7 @@ sealed class CR { @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("receivedGroupInvitationViaGroup") class ReceivedGroupInvitationViaGroup(val user: UserRef, val groupInfo: GroupInfo, val viaGroupInfo: GroupInfo, val viaMember: GroupMember, val memberRole: GroupMemberRole): CR() @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @@ -6869,10 +6985,13 @@ sealed class CR { // badges // the full user, not UserRef: its profile carries the badge that setUserBadge just stored @Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR() - @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeInvoice") class BadgeInvoice(val user: UserRef, val invoiceId: String): CR() + @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List = emptyList()): CR() @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): CR() @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() + @Serializable @SerialName("storePurchaseCredited") class StorePurchaseCredited(val user: UserRef, val invoiceId: String): CR() + @Serializable @SerialName("storePurchaseRefused") class StorePurchaseRefused(val user: UserRef, val invoiceId: String, val refusal: BadgeIssueFailure): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -6976,6 +7095,7 @@ sealed class CR { is LeftMemberUser -> "leftMemberUser" is GroupMembers -> "groupMembers" is ReceivedGroupInvitation -> "receivedGroupInvitation" + is ReceivedGroupInvitationViaGroup -> "receivedGroupInvitationViaGroup" is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberAccepted -> "memberAccepted" @@ -7060,10 +7180,13 @@ sealed class CR { is ArchiveImported -> "archiveImported" is AppSettingsR -> "appSettings" is BadgeRedeemed -> "badgeRedeemed" + is BadgeInvoice -> "badgeInvoice" is BadgeStateR -> "badgeState" is BadgeLedger -> "badgeLedger" is BadgeChanged -> "badgeChanged" is BadgeAlertR -> "badgeAlert" + is StorePurchaseCredited -> "storePurchaseCredited" + is StorePurchaseRefused -> "storePurchaseRefused" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -7167,6 +7290,7 @@ sealed class CR { is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") + is ReceivedGroupInvitationViaGroup -> withUser(user, "groupInfo: $groupInfo\nviaGroupInfo: $viaGroupInfo\nviaMember: $viaMember\nmemberRole: $memberRole") is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") @@ -7268,10 +7392,13 @@ sealed class CR { is ArchiveImported -> "${archiveErrors.map { it.string } }" is AppSettingsR -> json.encodeToString(appSettings) is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}") - is BadgeStateR -> withUser(user, json.encodeToString(badgeState)) + is BadgeInvoice -> withUser(user, invoiceId) + is BadgeStateR -> withUser(user, "${json.encodeToString(badgeState)}\nstorePurchases: ${json.encodeToString(storePurchases)}") is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger)) is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) + is StorePurchaseCredited -> withUser(user, "invoiceId: $invoiceId") + is StorePurchaseRefused -> withUser(user, "invoiceId: $invoiceId\nrefusal: ${refusal.tag}") is Response -> json is Invalid -> str } @@ -7327,6 +7454,7 @@ sealed class SimplexDomainError { sealed class BadgeRedeemError { val string: String get() = when (this) { is InvalidCode -> "invalidCode" + is InvalidReceipt -> "invalidReceipt" is ServiceNotConfigured -> "serviceNotConfigured" is BadgeActive -> "badgeActive" is ServiceError -> "serviceError ${serviceError.text}" @@ -7335,6 +7463,7 @@ sealed class BadgeRedeemError { is CredentialNotVerified -> "credentialNotVerified" } @Serializable @SerialName("invalidCode") object InvalidCode : BadgeRedeemError() + @Serializable @SerialName("invalidReceipt") object InvalidReceipt : BadgeRedeemError() @Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError() @Serializable @SerialName("badgeActive") object BadgeActive : BadgeRedeemError() @Serializable @SerialName("serviceError") class ServiceError(val serviceError: BadgeServiceErrorCode) : BadgeRedeemError() @@ -7356,12 +7485,14 @@ sealed class BadgeServiceErrorCode { object PaymentNotEntitled: BadgeServiceErrorCode() object PaymentPending: BadgeServiceErrorCode() object ProviderUnavailable: BadgeServiceErrorCode() + object ProviderNotConfigured: BadgeServiceErrorCode() object RateLimited: BadgeServiceErrorCode() object CodeInvalid: BadgeServiceErrorCode() object CodeUsed: BadgeServiceErrorCode() object CodeExpired: BadgeServiceErrorCode() object ReceiptInvalid: BadgeServiceErrorCode() object ReceiptUsed: BadgeServiceErrorCode() + object TestPurchase: BadgeServiceErrorCode() object Internal: BadgeServiceErrorCode() data class Unknown(val code: String): BadgeServiceErrorCode() @@ -7377,12 +7508,14 @@ sealed class BadgeServiceErrorCode { is PaymentNotEntitled -> "payment_not_entitled" is PaymentPending -> "payment_pending" is ProviderUnavailable -> "provider_unavailable" + is ProviderNotConfigured -> "provider_not_configured" is RateLimited -> "rate_limited" is CodeInvalid -> "code_invalid" is CodeUsed -> "code_used" is CodeExpired -> "code_expired" is ReceiptInvalid -> "receipt_invalid" is ReceiptUsed -> "receipt_used" + is TestPurchase -> "test_purchase" is Internal -> "internal" is Unknown -> code } @@ -7396,6 +7529,13 @@ fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) { is BadgeServiceErrorCode.UnsupportedVersion -> generalGetString(MR.strings.badges_error_unsupported_version) is BadgeServiceErrorCode.UnknownPurchaseKey -> generalGetString(MR.strings.badges_error_unknown_purchase) is BadgeServiceErrorCode.Internal -> generalGetString(MR.strings.badges_error_service_internal) + is BadgeServiceErrorCode.ReceiptInvalid -> generalGetString(MR.strings.badges_error_receipt_invalid) + is BadgeServiceErrorCode.TestPurchase -> generalGetString(MR.strings.badges_error_test_purchase) + is BadgeServiceErrorCode.ReceiptUsed -> generalGetString(MR.strings.badges_error_receipt_used) + is BadgeServiceErrorCode.PaymentPending -> generalGetString(MR.strings.badges_error_payment_pending) + is BadgeServiceErrorCode.ProviderUnavailable -> generalGetString(MR.strings.badges_error_provider_unavailable) + is BadgeServiceErrorCode.ProviderNotConfigured -> generalGetString(MR.strings.badges_error_provider_not_configured) + is BadgeServiceErrorCode.ProductUnavailable -> generalGetString(MR.strings.badges_error_product_unavailable) else -> null } @@ -7413,12 +7553,14 @@ object BadgeServiceErrorCodeSerializer : KSerializer { "payment_not_entitled" -> BadgeServiceErrorCode.PaymentNotEntitled "payment_pending" -> BadgeServiceErrorCode.PaymentPending "provider_unavailable" -> BadgeServiceErrorCode.ProviderUnavailable + "provider_not_configured" -> BadgeServiceErrorCode.ProviderNotConfigured "rate_limited" -> BadgeServiceErrorCode.RateLimited "code_invalid" -> BadgeServiceErrorCode.CodeInvalid "code_used" -> BadgeServiceErrorCode.CodeUsed "code_expired" -> BadgeServiceErrorCode.CodeExpired "receipt_invalid" -> BadgeServiceErrorCode.ReceiptInvalid "receipt_used" -> BadgeServiceErrorCode.ReceiptUsed + "test_purchase" -> BadgeServiceErrorCode.TestPurchase "internal" -> BadgeServiceErrorCode.Internal else -> BadgeServiceErrorCode.Unknown(v) } @@ -7541,7 +7683,8 @@ enum class RcvSwitchStatus { @SerialName("switch_started") SwitchStarted, @SerialName("sending_qadd") SendingQADD, @SerialName("sending_quse") SendingQUSE, - @SerialName("received_message") ReceivedMessage + @SerialName("received_message") ReceivedMessage, + @SerialName("received_qend") ReceivedQEND } @Serializable @@ -8001,7 +8144,7 @@ sealed class StoreError { is UserChatRelayNotFound -> "userChatRelayNotFound $chatRelayId" is GroupRelayNotFound -> "groupRelayNotFound $groupRelayId" is GroupRelayNotFoundByMemberId -> "groupRelayNotFoundByMemberId $groupMemberId" - is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $authorGroupMemberId" + is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $forwardedByGroupMemberId" is RemoteHostNotFound -> "remoteHostNotFound $remoteHostId" is RemoteHostUnknown -> "remoteHostUnknown" is RemoteHostDuplicateCA -> "remoteHostDuplicateCA" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index 140c1951ee..1da9d8ff40 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -6,7 +6,7 @@ import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.ui.theme.DefaultTheme import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import java.util.* enum class AppPlatform { @@ -25,6 +25,8 @@ expect val deviceName: String expect fun isAppVisibleAndFocused(): Boolean +expect fun appLinkSchemeRegistered(): Boolean + val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE else diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index ad0e9aaa5a..9d1c0b8769 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -8,7 +8,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword import chat.simplex.common.views.helpers.DatabaseUtils.randomDatabasePassword import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import java.io.File import java.nio.ByteBuffer diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt index 5af941fb42..56585f0739 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt @@ -4,6 +4,7 @@ interface CryptorInterface { fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? fun encryptText(text: String, alias: String): Pair fun deleteKey(alias: String) + fun keyStorage(alias: String): String? } expect val cryptor: CryptorInterface diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index cdd4140e3f..6dfbeceff8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.charleskorn.kaml.* import kotlinx.serialization.encodeToString import java.io.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index d7a241d5ee..47cc034bfb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -15,6 +15,7 @@ import java.io.InputStream import java.net.URI expect fun base64ToBitmap(base64ImageString: String): ImageBitmap +expect fun decodeBase64Image(base64: String, width: Int): ImageBitmap? expect fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String expect fun resizeImageToDataSize(image: ImageBitmap, usePng: Boolean, maxDataSize: Long): ByteArrayOutputStream expect fun cropToSquare(image: ImageBitmap): ImageBitmap diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index caaf66811c..88d4709dfa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -3,6 +3,7 @@ package chat.simplex.common.platform import androidx.compose.foundation.combinedClickable import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.ImageBitmap @@ -127,3 +128,5 @@ private fun ImageBitmap.blurredBy(radius: Int, drawnWidthDp: Float = CHAT_MEDIA_ while (maxOf(image.width, image.height) < BLURRED_MEDIA_WIDTH_DP / 2) image = image.scale(image.width * 2, image.height * 2) return image } + +expect fun newPasswordContentType(): ContentType diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index 89f23f3326..290d93db71 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -1,12 +1,13 @@ package chat.simplex.common.platform +import chat.simplex.common.AppLock import chat.simplex.common.model.* import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.chatlist.acceptContactRequest import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay enum class NotificationAction { @@ -96,6 +97,7 @@ abstract class NtfManager { } fun acceptCallAction(chatId: ChatId) { + AppLock.recheckAuthState() chatModel.clearOverlays.value = true val invitation = chatModel.callInvitations[chatId] if (invitation == null) { @@ -134,7 +136,12 @@ abstract class NtfManager { return if (md != null) { var res = "" for (ft in md) { - res += if (ft.format is Format.Secret) "..." else ft.text + res += when (ft.format) { + is Format.Secret -> "..." + is Format.Markup -> ft.format.token.previewText + is Format.Escape -> "" + else -> ft.text + } } res } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 7d126096e8..f4e902b967 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -13,6 +13,7 @@ import chat.simplex.common.views.badges.BadgeProduct import chat.simplex.common.views.badges.BadgePurchaseOutcome import chat.simplex.common.views.badges.BadgeStoreError import chat.simplex.common.views.badges.BadgeStoreProductId +import chat.simplex.common.views.badges.BadgeStoreReceipt import kotlinx.coroutines.Job import java.io.Closeable @@ -44,6 +45,9 @@ interface PlatformInterface { return emptyList() } suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable + suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} + suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) {} + suspend fun androidUnfinishedBadgePurchases(): List = emptyList() val androidApiLevel: Int? get() = null // The build distributed via Google Play, which has to follow its policies val androidIsPlayStoreBuild: Boolean get() = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index 20e0280acd..b6d6a4d771 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.serialization.Transient import java.util.UUID diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 843bacecec..e4a187783a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -45,7 +45,7 @@ import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.DeleteImageButton import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt index f8a37c39a7..ed971fc1e5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -1,7 +1,20 @@ package chat.simplex.common.views.badges +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.helpers.ModalViewId +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import java.text.NumberFormat import java.util.Currency import java.util.UUID @@ -29,11 +42,34 @@ val badgeStoreProductIds: List = BadgeLevel.entries.flatMap BadgePeriod.entries.map { badgeStoreProductId(level, it) } } -// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. -// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service -// learns which invoice a store transaction settles. +// the only products sent to the badge service: nothing delivers a subscription yet +val badgeOneTimeProductIds: Set = badgeStoreProductIds.filter { it.basePlanId == null }.map { it.productId }.toSet() + +// subscriptions are for sale once renewals are delivered +val badgePeriodsForSale: List = listOf(BadgePeriod.OneMonth) + +// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core. +// Sent to Play as obfuscatedAccountId and echoed back on the purchase. fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() +// the page's app flag rides in the fragment, which never reaches the service +fun badgePageUrl(linkReturns: Boolean): String = + if (linkReturns) "https://badges.simplex.chat/#/tier?app=true" + else "https://badges.simplex.chat/#/tier?app=desktop" +// the dev deployment, for testing store purchases: swap with the two lines above +// if (linkReturns) "https://smp7.simplex.im/#/tier?app=true" +// else "https://smp7.simplex.im/#/tier?app=desktop" + +// where the store allows a link out to the badge page: the US for now, a set expected to widen. +// An unknown country does not count, as this decides whether the store sees a link out of the app. +@Composable +fun badgeBrowserAllowed(): Boolean { + if (androidPlayStoreCountry.value == null) { + LaunchedEffect(Unit) { platform.androidLoadPlayStoreCountry() } + } + return androidPlayStoreCountry.value == "US" +} + // what the platform store knows about one product; ProductDetails cannot cross into commonMain data class BadgeProduct( val id: BadgeStoreProductId, @@ -58,10 +94,8 @@ data class BadgeStoreReceipt( // the token the badge service verifies with the Publisher API val token: String, val productId: String, - val orderId: String?, val invoiceId: String?, - // only set for test products - a real Play purchase has no environment to report - val environment: String? = null + val acknowledged: Boolean = false ) // TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens @@ -69,6 +103,9 @@ data class BadgeStoreReceipt( // receipt says so. const val useBadgeTestProducts = false +// test products stand in for a store, so the lane is offered without one +val badgeStoreAvailable: Boolean get() = useBadgeTestProducts || platform.androidHasPlatformStore + private fun testProduct(level: BadgeLevel, period: BadgePeriod, priceMicros: Long) = BadgeProduct(badgeStoreProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD") @@ -83,20 +120,29 @@ private val testBadgeProducts: List = listOf( sealed class BadgePurchaseOutcome { class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome() - object Pending: BadgePurchaseOutcome() + class Pending(val invoiceId: String?): BadgePurchaseOutcome() object Cancelled: BadgePurchaseOutcome() } +enum class BadgePurchaseState { + Issuing, + WaitingForApproval +} + sealed class BadgeStoreError: Exception() { class ProductUnavailable(val productId: String): BadgeStoreError() class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError() object StoreUnavailable: BadgeStoreError() + object NoActiveProfile: BadgeStoreError() + class ApiError(val err: ChatError): BadgeStoreError() override val message: String get() = when (this) { is ProductUnavailable -> "productUnavailable(productId: $productId)" is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)" is StoreUnavailable -> "storeUnavailable" + is NoActiveProfile -> "noActiveProfile" + is ApiError -> "apiError(${err.string})" } } @@ -106,12 +152,50 @@ object BadgeStore { private val state = mutableStateOf(LoadState.NotLoaded) // snapshot state so a composable reading only the products still recomposes when they arrive private val products = mutableStateOf>(emptyMap()) + // core's open store purchases for the profile they were read for: core knows whose a purchase is + private val storePurchases = mutableStateOf>?>(null) + // whether a store sheet this run opened has not returned + private val buying = mutableStateOf(false) + // by invoice id, so a pending purchase shows only under the profile whose record it names + private val waitingForApproval = mutableStateOf>(emptySet()) + // set once presentUnfinished has read the store, or failed to: until then, a slow payment completed while + // the app was closed, or a purchase it died before handing over, are both unknown, so canBuy refuses + private val reconciledOnce = mutableStateOf(false) + // the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run + private var sweepFailureShown = false - fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) { + fun purchaseState(userId: Long?): BadgePurchaseState? { + if (!badgeStoreAvailable) return null + val purchases = openStorePurchases(userId) + return when { + purchases.any { it.transactionRef != null } -> BadgePurchaseState.Issuing + purchases.any { it.invoiceId?.let(waitingForApproval.value::contains) == true } -> BadgePurchaseState.WaitingForApproval + else -> null + } + } + + fun creditError(userId: Long?): BadgeIssueFailure? = + openStorePurchases(userId).firstNotNullOfOrNull { it.creditError } + + val checkingPurchases: Boolean get() = badgeStoreAvailable && !reconciledOnce.value + + fun canBuy(userId: Long?): Boolean = + badgeStoreAvailable && reconciledOnce.value && !buying.value && purchaseState(userId) == null + + fun setStorePurchases(rhId: Long?, userId: Long, purchases: List) { + storePurchases.value = Triple(rhId, userId, purchases) + } + + private fun openStorePurchases(userId: Long?): List { + val (rhId, readFor, purchases) = storePurchases.value ?: return emptyList() + return if (rhId == chatModel.remoteHostId() && readFor == userId) purchases else emptyList() + } + + fun price(level: BadgeLevel, period: BadgePeriod, compact: Boolean = true): BadgePrice = when (state.value) { LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading LoadState.Loaded, LoadState.Failed -> { val p = products.value[badgeStoreProductId(level, period)] - if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable + if (p != null) BadgePrice.Price(if (compact) compactPrice(p) else p.displayPrice) else BadgePrice.Unavailable } } @@ -146,21 +230,189 @@ object BadgeStore { } } - suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome { + // A one-time purchase has its core record before the store charges, and every store outcome reaches it. + suspend fun purchase(level: BadgeLevel, period: BadgePeriod): BadgePurchaseOutcome { val id = badgeStoreProductId(level, period) if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId) - if (useBadgeTestProducts) { - return BadgePurchaseOutcome.Purchased( + // a subscription is never sent to core, so nothing would ever finish it later + if (id.productId !in badgeOneTimeProductIds) { + val outcome = storePurchase(id, newBadgeInvoiceId()) + if (outcome is BadgePurchaseOutcome.Purchased) finish(outcome.receipt) + return outcome + } + val rhId = chatModel.remoteHostId() + val userId = chatModel.currentUser.value?.userId ?: throw BadgeStoreError.NoActiveProfile + val invoiceId = chatModel.controller.apiCreateBadgeInvoice(rhId, userId) + withContext(Dispatchers.Main) { buying.value = true } + chatModel.controller.loadBadgeState(rhId) + try { + val outcome = storePurchase(id, invoiceId) + if (outcome is BadgePurchaseOutcome.Purchased) { + handOver(outcome.receipt) + } + return outcome + } finally { + withContext(Dispatchers.Main + NonCancellable) { buying.value = false } + } + } + + private suspend fun storePurchase(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome { + val outcome = if (useBadgeTestProducts) { + BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( token = "test-${UUID.randomUUID()}", productId = id.productId, - orderId = null, - invoiceId = invoiceId, - environment = "test products" + invoiceId = invoiceId ) ) + } else { + platform.androidPurchaseBadge(id, invoiceId) + } + if (outcome is BadgePurchaseOutcome.Pending && outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } + return outcome + } + + // Core holds the receipt and credits it; the purchase stays unfinished until core answers it credited + // or refused, as an unfinished purchase is what the store re-delivers if anything is lost on the way. + private suspend fun handOver(receipt: BadgeStoreReceipt) { + val rhId = chatModel.remoteHostId() + val userId = chatModel.currentUser.value?.userId ?: return + when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token))) { + is BadgePurchaseResult.Held -> { + // core holds it durably now, so Play must not refund it after three days unacknowledged + acknowledge(receipt) + withContext(Dispatchers.Main) { + // the answer is the owner's, which may be another profile and a hidden one + if (chatModel.controller.activeUser(rhId, r.user)) { + BadgeModel.set(rhId, r.user.userId, r.badgeState) + setStorePurchases(rhId, r.user.userId, r.storePurchases) + } + } + } + is BadgePurchaseResult.Credited -> { + withContext(Dispatchers.Main) { + if (chatModel.controller.activeUser(rhId, r.user)) { + BadgeModel.set(rhId, r.user.userId, r.badgeState) + chatModel.updateUser(r.user) + if (r.badgeState?.shown == true) appPrefs.supporterBannerShown.set(true) + } + } + finish(receipt) + } + // a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer + is BadgePurchaseResult.Failed -> + if (badgeReceiptRefused(r.err)) { + Log.e(TAG, "BadgeStore.handOver: ${r.err?.string}") + finish(receipt) + } else { + throw r.err?.let(BadgeStoreError::ApiError) ?: Exception("apiPurchaseBadge: unexpected response") + } + } + } + + suspend fun storePurchaseCredited(rhId: Long?, user: UserRef, invoiceId: String) { + withContext(Dispatchers.Main) { + // not isLastModalOpen: the purchase screens pushed over the badges screen close once the badge appears + val badgesOpen = ModalManager.start.hasModalOpen(ModalViewId.BADGES) || ModalManager.end.hasModalOpen(ModalViewId.BADGES) + // an open badges screen is the notice only for the active profile, whose badge it shows + if (!chatModel.controller.activeUser(rhId, user) || !badgesOpen) showBadgeAddedAlert(rhId, user) + } + storePurchaseResolved(rhId, user, invoiceId) + } + + suspend fun storePurchaseRefused(rhId: Long?, user: UserRef, invoiceId: String, refusal: BadgeIssueFailure) { + withContext(Dispatchers.Main) { + if (chatModel.controller.activeUser(rhId, user) && openStorePurchases(user.userId).any { it.invoiceId == invoiceId }) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_purchase_error), text = refusal.purchaseText) + } + } + storePurchaseResolved(rhId, user, invoiceId) + } + + private suspend fun storePurchaseResolved(rhId: Long?, user: UserRef, invoiceId: String) { + // the row goes before the store is read, or the screen shows it open while the store answers; + // the purchase is finished whoever owns it, as only the app can, while the cached row is the active profile's + withContext(Dispatchers.Main) { + val (readOn, readFor, purchases) = storePurchases.value ?: return@withContext + if (chatModel.controller.activeUser(rhId, user) && readOn == rhId && readFor == user.userId) { + storePurchases.value = Triple(readOn, readFor, purchases.filter { it.invoiceId != invoiceId }) + } + } + if (rhId != null || useBadgeTestProducts || !platform.androidHasPlatformStore) return + try { + platform.androidUnfinishedBadgePurchases() + .firstNotNullOfOrNull { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.takeIf { r -> r.invoiceId == invoiceId } } + ?.let { finish(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + // the purchase stays unfinished, and the next sweep hands it over and finishes it + Log.e(TAG, "BadgeStore.storePurchaseResolved: ${e.message}") + } + } + + // at launch, on return to the foreground and on a profile switch, never on a timer + suspend fun presentUnfinished() { + try { + if (!useBadgeTestProducts && platform.androidHasPlatformStore) { + try { + val purchases = platform.androidUnfinishedBadgePurchases() + // Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here + withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() } + purchases.forEach { reconcile(it) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}") + } + } + chatModel.controller.loadBadgeState(chatModel.remoteHostId()) + } finally { + withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true } + } + } + + suspend fun reconcile(outcome: BadgePurchaseOutcome) { + when (outcome) { + is BadgePurchaseOutcome.Purchased -> + if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt) + // the purchase stays unfinished, so the next sweep hands it over again + else try { + handOver(outcome.receipt) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "BadgeStore.reconcile: ${e.stackTraceToString()}") + withContext(Dispatchers.Main) { + if (!sweepFailureShown) { + sweepFailureShown = true + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true) + else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + ) + } + } + } + is BadgePurchaseOutcome.Pending -> + if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } + is BadgePurchaseOutcome.Cancelled -> {} + } + } + + private suspend fun acknowledge(receipt: BadgeStoreReceipt) { + try { + if (!useBadgeTestProducts) platform.androidAcknowledgeBadgePurchase(receipt) + } catch (e: Exception) { + // the next sweep hands the purchase over again, and acknowledges it then + Log.e(TAG, "BadgeStore.acknowledge: ${e.message}") + } + } + + private suspend fun finish(receipt: BadgeStoreReceipt) { + try { + if (!useBadgeTestProducts) platform.androidFinishBadgePurchase(receipt) + } catch (e: Exception) { + // still unfinished: the store re-delivers it, and the next answer for it finishes it + Log.e(TAG, "BadgeStore.finish: ${e.message}") } - return platform.androidPurchaseBadge(id, invoiceId) } private fun startLoading(): Boolean = when (state.value) { @@ -185,3 +437,11 @@ private fun compactPrice(product: BadgeProduct): String { product.displayPrice } } + +// the codes core refuses a receipt with for good, after which the store may stop re-delivering it; +// a sweep that hands over a receipt refused while the app was not running learns it only from this answer +private fun badgeReceiptRefused(err: ChatError?): Boolean { + val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError + val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError + return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed || code is BadgeServiceErrorCode.TestPurchase +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt index 03fabf817e..535eb920ca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt @@ -15,11 +15,11 @@ import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.NameWithBadge import chat.simplex.common.views.helpers.ProfileImage -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Instant @Composable -fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) { +fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier) { val user = chatModel.currentUser.value val displayName = user?.displayName ?: stringResource(MR.strings.badges_preview_my_nickname) val previewBadge = LocalBadge( @@ -29,15 +29,12 @@ fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: ) Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { ProfileImage(size = 128.dp, image = user?.image) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - NameWithBadge( - name = displayName, - badge = previewBadge, - style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - trailing() - } + NameWithBadge( + name = displayName, + badge = previewBadge, + style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt new file mode 100644 index 0000000000..fbb3d1a017 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt @@ -0,0 +1,107 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +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.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.res.* + +@Composable +fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) { + val purchasing = remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) + + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(MR.strings.badges_check_your_order_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Column( + Modifier + .fillMaxWidth() + .padding(top = 20.dp) + .clip(RoundedCornerShape(16.dp)) + .background(sectionCardColor()) + .padding(horizontal = 24.dp, vertical = 12.dp) + ) { + OrderRow(MR.strings.badges_your_badge, stringResource(level.title)) + OrderRow(MR.strings.badges_order_duration, stringResource(period.label)) + Divider(Modifier.padding(top = 8.dp, bottom = 6.dp)) + OrderRow(MR.strings.badges_order_total, period.priceText(BadgeStore.price(level, period, compact = false))) + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PayButton(level, period, purchasing) + BadgeBillingFooter(period) + } + } +} + +@Composable +private fun OrderRow(title: StringResource, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.secondary) + Spacer(Modifier.weight(1f)) + Text(value, style = MaterialTheme.typography.body1, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun PayButton(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState) { + val price = BadgeStore.price(level, period, compact = false) + val (labelId, labelArg) = period.payLabel(price) + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = labelId, + labelArg = labelArg, + onboarding = null, + enabled = price.canPurchase && !purchasing.value && BadgeStore.canBuy(chatModel.currentUser.value?.userId), + onclick = { purchase(level, period, purchasing) } + ) +} + +private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState) { + purchasing.value = true + // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls + withLongRunningApi { + try { + BadgeStore.purchase(level, period) + purchasing.value = false + } catch (e: Exception) { + Log.e(TAG, "BadgesCheckOrderView.purchase: ${e.stackTraceToString()}") + purchasing.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true) + else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt similarity index 69% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt index 161702704b..8925f8718f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesChooseBadgeView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum class BadgeLevel { @@ -34,16 +34,22 @@ enum class BadgeLevel { Legend -> MR.strings.badges_level_legend } - val filesDescription: StringResource + val fileSize: StringResource get() = when (this) { - Supporter -> MR.strings.badges_level_supporter_files - Legend -> MR.strings.badges_level_legend_files + Supporter -> MR.strings.badges_level_supporter_file_size + Legend -> MR.strings.badges_level_legend_file_size } - val tagline: StringResource + val fileStorage: StringResource get() = when (this) { - Supporter -> MR.strings.badges_level_supporter_tagline - Legend -> MR.strings.badges_level_legend_tagline + Supporter -> MR.strings.badges_level_supporter_file_storage + Legend -> MR.strings.badges_level_legend_file_storage + } + + val summary: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter_summary + Legend -> MR.strings.badges_level_legend_summary } val badgeType: BadgeType @@ -54,10 +60,11 @@ enum class BadgeLevel { } @Composable -fun BadgesYourLevelView(modalManager: ModalManager) { +fun BadgesChooseBadgeView(modalManager: ModalManager, unwindToDepth: Int) { var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -66,7 +73,7 @@ fun BadgesYourLevelView(modalManager: ModalManager) { maxIntrinsicSize = true, ) { Text( - stringResource(MR.strings.badges_your_level_title), + stringResource(MR.strings.badges_choose_your_badge_title), style = MaterialTheme.typography.h1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary, @@ -74,15 +81,17 @@ fun BadgesYourLevelView(modalManager: ModalManager) { modifier = Modifier.fillMaxWidth() ) - BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) { - Icon( - painterResource(MR.images.ic_keyboard_arrow_down), - contentDescription = null, - tint = MaterialTheme.colors.primary - ) - } + Text( + stringResource(MR.strings.badges_choose_your_badge_lede), + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) - Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) + + Spacer(Modifier.weight(1f).heightIn(min = 12.dp)) // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height // when 2-line labels at large fonts would otherwise size them differently. @@ -94,17 +103,18 @@ fun BadgesYourLevelView(modalManager: ModalManager) { LevelCard(BadgeLevel.Legend, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it } } - Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 12.dp)) // Nested Column with no spacing so the TextButtonBelowOnboardingButton sits directly under // the action button (matches onboarding pattern where its own 7.5dp top padding is the gap). Column(horizontalAlignment = Alignment.CenterHorizontally) { - ContinueButton(selectedLevel, modalManager) - TextButtonBelowOnboardingButton( - text = stringResource(MR.strings.badges_how_it_works_button), - icon = painterResource(MR.images.ic_info), - onClick = { modalManager.showModal { BadgesHowItWorksView() } } - ) + ContinueButton(selectedLevel, modalManager, unwindToDepth) + // redeeming a code is here only when Support SimpleX offers the browser instead + if (badgeBrowserAllowed()) { + RedeemCodeButton(modalManager, unwindToDepth) + } else { + TextButtonBelowOnboardingButton("", null) + } } } } @@ -123,7 +133,7 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo .background(cardBackground, shape) .border(2.dp, borderColor, shape) .clickable { onSelect(level) } - .padding(vertical = 20.dp, horizontal = 12.dp), + .padding(vertical = 16.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp) ) { @@ -131,22 +141,25 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo painterResource(badgeImage(level.badgeType)), contentDescription = null, contentScale = ContentScale.Fit, - modifier = Modifier.size(60.dp) + modifier = Modifier.size(44.dp) ) Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(level.fileSize), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + Text(stringResource(level.fileStorage), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + } } } @Composable -private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager) { +private fun ContinueButton(selectedLevel: BadgeLevel, modalManager: ModalManager, unwindToDepth: Int) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), labelId = MR.strings.badges_continue, onboarding = null, onclick = { - modalManager.showModal { BadgesPayView(selectedLevel) } + modalManager.showModal { BadgesHowLongView(selectedLevel, modalManager, unwindToDepth) } } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt index cb300b183d..28cf4c40b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.views.onboarding.ReadableTextWithLink -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesHowItWorksView() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt similarity index 54% rename from apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt rename to apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt index d7a10f38f7..8b58049276 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt @@ -9,10 +9,9 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.ClipboardManager -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -23,7 +22,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton -import chat.simplex.res.MR +import chat.simplex.res.* // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum class BadgePeriod { @@ -45,6 +44,13 @@ enum class BadgePeriod { Annual -> MR.strings.badges_period_annual } + val months: Int + get() = when (this) { + OneMonth -> 1 + Monthly -> 1 + Annual -> 12 + } + @Composable fun priceText(price: BadgePrice): String = when (price) { is BadgePrice.Loading -> "…" @@ -68,12 +74,11 @@ enum class BadgePeriod { } @Composable -fun BadgesPayView(level: BadgeLevel) { - var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) } - val purchasing = remember { mutableStateOf(false) } - val clipboard = LocalClipboardManager.current +fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager, unwindToDepth: Int) { + var selectedPeriod by remember { mutableStateOf(if (BadgePeriod.Monthly in badgePeriodsForSale) BadgePeriod.Monthly else BadgePeriod.OneMonth) } LaunchedEffect(Unit) { BadgeStore.load() } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -82,7 +87,7 @@ fun BadgesPayView(level: BadgeLevel) { maxIntrinsicSize = true, ) { Text( - stringResource(level.title), + stringResource(MR.strings.badges_how_long_title), style = MaterialTheme.typography.h1, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary, @@ -90,51 +95,58 @@ fun BadgesPayView(level: BadgeLevel) { modifier = Modifier.fillMaxWidth() ) - BadgeUserPreview(level = level, modifier = Modifier.padding(top = 4.dp)) - Text( - stringResource(level.tagline), + stringResource(level.summary), style = MaterialTheme.typography.body1, - color = MaterialTheme.colors.onBackground, + color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 4.dp) + modifier = Modifier.fillMaxWidth() ) + BadgeUserPreview(level = level, modifier = Modifier.padding(top = 4.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) - // IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one - + // IntrinsicSize.Max + fillMaxHeight on children so the cards all match the tallest one - // only Annual carries a savings line, and prices wrap at large fonts. Row( Modifier.fillMaxWidth().height(IntrinsicSize.Max), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + BadgePeriod.entries.forEach { period -> + PeriodCard(level, period, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + } } Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) - // Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a - // TextButton so the footer has no hover/click affordance. Column(horizontalAlignment = Alignment.CenterHorizontally) { - PayButton(level, selectedPeriod, purchasing, clipboard) - Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()), - Modifier.padding(vertical = 5.dp), - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.secondary, - textAlign = TextAlign.Center - ) - } + ContinueButton(level, selectedPeriod, modalManager, unwindToDepth) + BadgeBillingFooter(selectedPeriod) } } } +// Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a +// TextButton so the footer has no hover/click affordance. +@Composable +fun BadgeBillingFooter(period: BadgePeriod) { + Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + stringResource(billingFooter(period)).format(billingDate(period)), + Modifier.padding(vertical = 5.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } +} + @Composable private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { val isSelected = period == selectedPeriod + val forSale = period in badgePeriodsForSale + val textColor = if (forSale) Color.Unspecified else MaterialTheme.colors.secondary val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) @@ -145,7 +157,7 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B .clip(shape) .background(cardBackground, shape) .border(2.dp, borderColor, shape) - .clickable { onSelect(period) } + .clickable(enabled = forSale) { onSelect(period) } .padding(vertical = 20.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) @@ -154,10 +166,10 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B painterResource(period.icon), contentDescription = null, tint = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp).alpha(if (forSale) 1f else 0.4f) ) - Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + Text(stringResource(period.label), style = MaterialTheme.typography.body2, color = textColor, textAlign = TextAlign.Center) + Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, fontWeight = FontWeight.SemiBold, color = textColor, textAlign = TextAlign.Center) val percent = savingsPercent(level, period) if (percent != null) { Text( @@ -173,76 +185,15 @@ private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: B private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? = if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null -// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and -// this button stays disabled - it will offer Stripe/crypto payment instead @Composable -private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { - val price = BadgeStore.price(level, selectedPeriod) - val (labelId, labelArg) = selectedPeriod.payLabel(price) +private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = labelId, - labelArg = labelArg, + labelId = MR.strings.badges_continue, onboarding = null, - enabled = price.canPurchase && !purchasing.value, - onclick = { purchase(level, selectedPeriod, purchasing, clipboard) } - ) -} - -private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { - val invoiceId = newBadgeInvoiceId() - purchasing.value = true - // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls - withLongRunningApi { - try { - val outcome = BadgeStore.purchase(level, period, invoiceId) - purchasing.value = false - when (outcome) { - is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard) - is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_purchase_pending), - text = generalGetString(MR.strings.badges_purchase_pending_desc) - ) - is BadgePurchaseOutcome.Cancelled -> {} - } - } catch (e: Exception) { - Log.e(TAG, "BadgesPayView.purchase: ${e.stackTraceToString()}") - purchasing.value = false - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_purchase_error), - text = e.toString() - ) + onclick = { + modalManager.showModal(cardScreen = true) { BadgesCheckOrderView(level, selectedPeriod, modalManager, unwindToDepth) } } - } -} - -// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. -private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) { - val returnedInvoice = when (receipt.invoiceId) { - null -> "none" - invoiceId -> "yes" - else -> "mismatch: ${receipt.invoiceId}" - } - val lines = mutableListOf( - "Product: ${receipt.productId}", - "Invoice: $invoiceId", - "Invoice returned by Google: $returnedInvoice", - "Order: ${receipt.orderId ?: "none"}" - ) - if (receipt.environment != null) lines.add("Environment: ${receipt.environment}") - lines.add("Token: ${receipt.token.length} bytes") - val summary = lines.joinToString("\n") - Log.d(TAG, "badge purchase succeeded\n$summary") - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.badges_purchase_successful), - text = summary, - confirmText = "Copy token", - onConfirm = { - clipboard.setText(AnnotatedString(receipt.token)) - showToast(generalGetString(MR.strings.copied)) - }, - dismissText = generalGetString(MR.strings.ok), - parseHtml = false ) } @@ -251,9 +202,10 @@ private fun billingFooter(period: BadgePeriod): StringResource = when (period) { BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month } -// TODO [badges] source the actual date from the purchase state machine when wired. -private fun stubBillingDate(): String { - val date = java.time.LocalDate.of(2026, 7, 22) +// TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld); +// a top-up must count from the end of the existing balance +private fun billingDate(period: BadgePeriod): String { + val date = java.time.LocalDate.now().plusMonths(period.months.toLong()) val formatter = java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.LONG) return date.format(formatter) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt index 2783727621..9420f323ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt @@ -39,7 +39,7 @@ import chat.simplex.common.views.helpers.ModalView import chat.simplex.common.views.helpers.ShareButton import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.helpers.withBGApi -import chat.simplex.res.MR +import chat.simplex.res.* import kotlin.math.abs @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt new file mode 100644 index 0000000000..4648a12774 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPurchaseStateView.kt @@ -0,0 +1,87 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeIssueFailure +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton +import chat.simplex.res.* + +@Composable +fun BadgesPurchaseStateView(title: StringResource, message: StringResource?, failure: BadgeIssueFailure? = null, onDismiss: () -> Unit) { + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + if (message != null) { + Text( + stringResource(message), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + + if (failure != null) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(painterResource(MR.images.ic_warning), contentDescription = null, tint = Color.Red) + Text( + failure.purchaseText, + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(Modifier.weight(1f)) + + CircularProgressIndicator( + Modifier.size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + TextButtonBelowOnboardingButton(stringResource(MR.strings.badges_dismiss), onDismiss) + TextButtonBelowOnboardingButton("", null) + } + } +} + +val BadgePurchaseState.title: StringResource + get() = when (this) { + BadgePurchaseState.Issuing -> MR.strings.badges_issuing_title + BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_title + } + +val BadgePurchaseState.message: StringResource + get() = when (this) { + BadgePurchaseState.Issuing -> MR.strings.badges_issuing_body + BadgePurchaseState.WaitingForApproval -> MR.strings.badges_waiting_for_approval_body + } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt index 9ebaf9bcdc..094f623032 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -32,7 +33,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* private const val badgeCodePrefix = "SB" private const val badgeCodeBodyLength = 20 @@ -57,13 +58,60 @@ private fun formatBadgeCodeInput(s: String): String { return groups.joinToString("-") } +sealed class BadgeRedeemOutcome { + object Redeemed: BadgeRedeemOutcome() + class Refused(val message: String): BadgeRedeemOutcome() + object Cancelled: BadgeRedeemOutcome() +} + +// sets the badge before returning, so a caller that dismisses on Redeemed lands on Your Badge +// instead of showing the switch from Support. Cancelled when the user cancels the retry alert. +suspend fun redeemBadgeCode(rhId: Long?, user: User, code: String): BadgeRedeemOutcome = + when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, code)) { + null -> BadgeRedeemOutcome.Cancelled + is BadgeRedeemResult.Redeemed -> withContext(Dispatchers.Main) { + val badgeState = result.badgeState + BadgeModel.set(rhId, user.userId, badgeState) + chatModel.updateUser(result.user) + if (badgeState != null && !badgeState.shown) { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + BadgeRedeemOutcome.Refused( + generalGetString(if (result.newBadge) MR.strings.badges_error_badge_ended else MR.strings.badges_error_code_used) + ) + } else { + appPrefs.supporterBannerShown.set(true) + BadgeRedeemOutcome.Redeemed + } + } + is BadgeRedeemResult.Failed -> { + Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}") + BadgeRedeemOutcome.Refused(chatModel.controller.redeemErrorText(result.err, purchase = false)) + } + } + +fun showCannotRedeemAlert(message: String) { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.badges_error_title), text = message) +} + +// a hidden profile is named only while it is the active one, which is unlocked and on screen +fun showBadgeAddedAlert(rhId: Long?, user: UserLike) { + val owner = if (chatModel.controller.activeUser(rhId, user)) chatModel.currentUser.value + else chatModel.users.firstOrNull { it.user.remoteHostId == rhId && it.user.userId == user.userId }?.user?.takeIf { !it.hidden } + if (owner != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_link_added_title), + text = String.format(generalGetString(MR.strings.badges_link_added_profile), owner.displayName) + ) + } +} + @Composable -fun BadgesRedeemCodeView(modalManager: ModalManager) { +fun BadgesRedeemCodeView(modalManager: ModalManager, unwindToDepth: Int) { val rhId = remember { chatModel.remoteHostId() } - val supporterBannerShown = remember { appPrefs.supporterBannerShown } val code = remember { mutableStateOf(TextFieldValue("")) } val canonicalCode = remember { mutableStateOf(null) } val submitting = remember { mutableStateOf(false) } + CloseWhenSupportGivesWay(modalManager, unwindToDepth) // when the text is unchanged, the field's own value is kept: it carries the cursor position and the // keyboard's composition state, which BasicTextField loses unless they are passed back to it @@ -78,37 +126,14 @@ fun BadgesRedeemCodeView(modalManager: ModalManager) { val user = chatModel.currentUser.value ?: return submitting.value = true withBGApi { - when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, sending)) { - null -> withContext(Dispatchers.Main) { submitting.value = false } - is BadgeRedeemResult.Redeemed -> { - val badgeState = result.badgeState - withContext(Dispatchers.Main) { - submitting.value = false - // set before dismissing: BadgesView then switches Support to Your Badge while this screen - // still covers it, so the pop lands on Your Badge instead of showing the switch - BadgeModel.set(rhId, user.userId, badgeState) - chatModel.updateUser(result.user) - if (badgeState != null && !badgeState.shown) { - // a replay adds no purchase; a fresh code's badge can be retired on arrival - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_error_title), - text = generalGetString(if (result.newBadge) MR.strings.badges_error_badge_ended else MR.strings.badges_error_code_used) - ) - } else { - supporterBannerShown.set(true) - modalManager.closeModal() - } - } - } - is BadgeRedeemResult.Failed -> { - Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}") - withContext(Dispatchers.Main) { - submitting.value = false - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_error_title), - text = chatModel.controller.redeemErrorText(result.err) - ) - } + val outcome = redeemBadgeCode(rhId, user, sending) + withContext(Dispatchers.Main) { + submitting.value = false + when (outcome) { + // a shown badge closes this screen itself, and a second close would take the screen beneath + is BadgeRedeemOutcome.Redeemed -> if (currentShownBadge() == null) modalManager.closeModal() + is BadgeRedeemOutcome.Refused -> showCannotRedeemAlert(outcome.message) + is BadgeRedeemOutcome.Cancelled -> {} } } } @@ -244,3 +269,189 @@ private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) { onclick = onClick ) } + +// the open screen's own state, not a copy of its step, so the two cannot disagree +private var badgeLinkStep: MutableState? = null + +private fun isBadgeLinkOpen(): Boolean = + ModalManager.start.hasModalOpen(ModalViewId.BADGE_LINK) + +fun isBadgeLinkIssuing(): Boolean = + isBadgeLinkOpen() && badgeLinkStep?.value == BadgeLinkStep.Issuing + +fun openBadgeLink(rhId: Long?, codeText: String) { + val code = parseBadgeCode(codeText) + ?: return showCannotRedeemAlert(generalGetString(MR.strings.badges_error_invalid_code)) + // opens over the chat list, as iOS dismisses every sheet before a link + ModalManager.closeAllModalsEverywhere() + // held by the modal, not remembered: a modal is composed only while on top, and rotation recreates the activity, + // either of which would reset remembered state to Confirming with a request in flight + val step = mutableStateOf(BadgeLinkStep.Confirming) + ModalManager.start.showCustomModal(id = ModalViewId.BADGE_LINK) { close -> + BadgesRedeemLinkView(rhId, code, step, close) + } + badgeLinkStep = step +} + +enum class BadgeLinkStep { + Confirming, + Issuing, + Redeemed, + ViewingBadge +} + +// Any web page can send a badge link, and a profile holds one badge at a time, +// so this screen asks before redeeming, names the profile, and offers nothing but the redemption. +// It is a code redemption with the code hidden, so it inherits the redeem screen's behaviour: an +// interrupted request is not resumed, and the code stays on the page that issued the link. +@Composable +fun BadgesRedeemLinkView(rhId: Long?, code: String, step: MutableState, close: () -> Unit) { + fun isOpen(): Boolean = + badgeLinkStep === step && isBadgeLinkOpen() + + fun isShowing(): Boolean = + isOpen() && ModalManager.start.isLastModalOpen(ModalViewId.BADGE_LINK) + + // An outcome can arrive after this screen was closed or covered: it must not close another screen, + // and a covered one goes back to asking, so it is never left locked on the spinner. + fun closeIfShowing() { + if (isShowing()) { + close() + } else if (isOpen()) { + step.value = BadgeLinkStep.Confirming + } + } + + fun redeemFromLink() { + // a second tap before the screen changes must not send the code again + if (step.value != BadgeLinkStep.Confirming) return + val user = chatModel.currentUser.value ?: return closeIfShowing() + step.value = BadgeLinkStep.Issuing + withBGApi { + val outcome = redeemBadgeCode(rhId, user, code) + withContext(Dispatchers.Main) { + when (outcome) { + is BadgeRedeemOutcome.Redeemed -> { + if (!isShowing()) showBadgeAddedAlert(rhId, user) + // a covered screen is not left on the spinner; a closed one is not written to + if (isOpen()) step.value = BadgeLinkStep.Redeemed + } + is BadgeRedeemOutcome.Refused -> { + closeIfShowing() + showCannotRedeemAlert(outcome.message) + } + is BadgeRedeemOutcome.Cancelled -> closeIfShowing() + } + } + } + } + + // read for the profile the screen names, when it renders, so the two cannot disagree; + // core refuses a code while the profile shows a badge + val profileHasBadge = BadgeModel.isCurrent(rhId, chatModel.currentUser.value?.userId) && BadgeModel.badgeState.value?.shown == true + + when (step.value) { + BadgeLinkStep.Confirming -> ModalView(::closeIfShowing) { + if (profileHasBadge) { + BadgeHeld(onViewBadge = { step.value = BadgeLinkStep.ViewingBadge }, onCancel = ::closeIfShowing) + } else { + Confirming(onConfirm = ::redeemFromLink, onCancel = ::closeIfShowing) + } + } + BadgeLinkStep.Issuing -> ModalView(::closeIfShowing) { BeingIssued(onDismiss = ::closeIfShowing) } + BadgeLinkStep.Redeemed, BadgeLinkStep.ViewingBadge -> BadgesView(ModalManager.start, close) + } +} + +// the screen cannot tell a repeated link for the badge it shows from a code for another badge, +// so it asserts neither +@Composable +private fun BadgeHeld(onViewBadge: () -> Unit, onCancel: () -> Unit) { + LinkStep( + MR.strings.badges_link_held_title, + primary = MR.strings.badges_link_view_badge to onViewBadge, + textButton = MR.strings.cancel_verb to onCancel + ) { + LinkText(stringResource(MR.strings.badges_link_held_other_profile)) + LinkText(stringResource(MR.strings.badges_link_held_page)) + } +} + +@Composable +private fun Confirming(onConfirm: () -> Unit, onCancel: () -> Unit) { + LinkStep( + MR.strings.badges_link_confirm_title, + primary = MR.strings.badges_link_add_badge to onConfirm, + textButton = MR.strings.cancel_verb to onCancel + ) { + LinkText(String.format(stringResource(MR.strings.badges_link_confirm_profile), chatModel.currentUser.value?.displayName ?: "")) + } +} + +// leaving does not cancel: the badge is still added after the screen closes +@Composable +private fun BeingIssued(onDismiss: () -> Unit) { + LinkStep(MR.strings.badges_issuing_title, textButton = MR.strings.badges_dismiss to onDismiss) { + LinkText(stringResource(MR.strings.badges_link_issuing_profile)) + Spacer(Modifier.weight(1f)) + CircularProgressIndicator( + Modifier.size(30.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + } +} + +// with no primary, the text button takes its place, so a button does not move between steps +@Composable +private fun LinkStep( + title: StringResource, + primary: Pair Unit>? = null, + textButton: Pair Unit>, + content: @Composable ColumnScope.() -> Unit +) { + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + content() + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (primary != null) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = primary.first, + onboarding = null, + onclick = primary.second + ) + TextButtonBelowOnboardingButton(stringResource(textButton.first), textButton.second) + } else { + TextButtonBelowOnboardingButton(stringResource(textButton.first), textButton.second) + TextButtonBelowOnboardingButton("", null) + } + } + } +} + +@Composable +private fun LinkText(text: String) { + Text( + text, + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt index 4f1cc2ed66..c0e1c968b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -29,10 +29,10 @@ import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.onboarding.HowItWorks import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton -import chat.simplex.res.MR +import chat.simplex.res.* @Composable -fun BadgesSupportSimplexView(modalManager: ModalManager) { +fun BadgesSupportSimplexView(modalManager: ModalManager, unwindToDepth: Int) { ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -55,8 +55,6 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) { modifier = Modifier.fillMaxWidth() ) - // TODO [badges] restore WhyBuiltButton() when in-app purchase lands: the level screen - // returns to the flow and HowItWorksButton() moves there, leaving this one alone here. HowItWorksButton(modalManager) Spacer(Modifier.weight(1f)) @@ -65,22 +63,37 @@ fun BadgesSupportSimplexView(modalManager: ModalManager) { Spacer(Modifier.weight(1f)) + // In-app purchase is not released: the badge is chosen on the site, and the code redeemed + // here. Revert this commit to restore the block below. + // Column(horizontalAlignment = Alignment.CenterHorizontally) { + // if (badgeStoreAvailable) { + // ChooseLevelButton(modalManager, unwindToDepth) + // if (badgeBrowserAllowed()) { + // BuyInBrowserTextButton() + // } else { + // RedeemCodeButton(modalManager, unwindToDepth) + // } + // } else { + // BuyInBrowserButton(modalManager, unwindToDepth) + // RedeemCodeButton(modalManager, unwindToDepth) + // } + // } Column(horizontalAlignment = Alignment.CenterHorizontally) { - RedeemCodeButton(modalManager) - GetCodeButton() + ChooseBadgeOnSiteButton(modalManager, unwindToDepth) + RedeemCodeButton(modalManager, unwindToDepth) } } } -// the in-app purchase path, kept compiling and uncalled until payments return after the MVP @Composable -private fun ChooseLevelButton(modalManager: ModalManager) { +private fun ChooseLevelButton(modalManager: ModalManager, unwindToDepth: Int) { OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.badges_choose_your_level, + labelId = MR.strings.badges_choose_your_badge_title, onboarding = null, + enabled = BadgeStore.canBuy(chatModel.currentUser.value?.userId), onclick = { - modalManager.showModal { BadgesYourLevelView(modalManager) } + modalManager.showModal { BadgesChooseBadgeView(modalManager, unwindToDepth) } } ) } @@ -111,23 +124,56 @@ private fun HowItWorksButton(modalManager: ModalManager) { } @Composable -private fun RedeemCodeButton(modalManager: ModalManager) { +fun RedeemCodeButton(modalManager: ModalManager, unwindToDepth: Int) { + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_redeem_code_button), + onClick = { modalManager.showModal { BadgesRedeemCodeView(modalManager, unwindToDepth) } } + ) +} + +@Composable +private fun BuyInBrowserTextButton() { + val uriHandler = LocalUriHandler.current + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_buy_in_browser), + onClick = { uriHandler.openUriCatching(badgePageUrl(appLinkSchemeRegistered())) } + ) +} + +// In-app purchase is not released: delete with the commented block above to restore it. +@Composable +private fun ChooseBadgeOnSiteButton(modalManager: ModalManager, unwindToDepth: Int) { + val uriHandler = LocalUriHandler.current OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = MR.strings.badges_redeem_code_button, + labelId = MR.strings.badges_choose_your_badge_title, onboarding = null, onclick = { - modalManager.showModal { BadgesRedeemCodeView(modalManager) } + val linkReturns = appLinkSchemeRegistered() + uriHandler.openUriCatching(badgePageUrl(linkReturns)) + // with no scheme to bring the code back, the code is pasted into this screen, opened beside the browser + if (!linkReturns && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) { + modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager, unwindToDepth) } + } } ) } @Composable -private fun GetCodeButton() { +private fun BuyInBrowserButton(modalManager: ModalManager, unwindToDepth: Int) { val uriHandler = LocalUriHandler.current - TextButtonBelowOnboardingButton( - text = stringResource(MR.strings.badges_get_your_code), - onClick = { uriHandler.openExternalLink("https://simplex.chat/badges/") } + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_buy_in_browser, + onboarding = null, + onclick = { + val linkReturns = appLinkSchemeRegistered() + uriHandler.openUriCatching(badgePageUrl(linkReturns)) + // with no scheme to bring the code back, the code is pasted into this screen, opened beside the browser + if (!linkReturns && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) { + modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager, unwindToDepth) } + } + } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt index ff2660497e..0e91b9ec7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -2,34 +2,62 @@ package chat.simplex.common.views.badges import androidx.compose.animation.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import chat.simplex.common.model.BadgeModel import chat.simplex.common.model.BadgeState import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.ModalView +import chat.simplex.common.views.helpers.ModalViewId +import chat.simplex.res.* @OptIn(ExperimentalAnimationApi::class) @Composable fun BadgesView(modalManager: ModalManager, close: () -> Unit) { - val shownBadge: BadgeState? = run { - if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return@run null - val badgeState = BadgeModel.badgeState.value - if (badgeState != null && badgeState.shown) badgeState else null - } + val shownBadge = currentShownBadge() + val unwindToDepth = remember { modalManager.openModalCount() } // the card look is a modal setting, so the modal is composed here to follow the screen shown ModalView(close, cardScreen = shownBadge != null) { - AnimatedContent(targetState = shownBadge, transitionSpec = { fadeIn() with fadeOut() }, contentKey = { it != null }) { badgeState -> + AnimatedContent( + targetState = Triple(shownBadge, BadgeStore.purchaseState(chatModel.currentUser.value?.userId), BadgeStore.checkingPurchases), + transitionSpec = { fadeIn() with fadeOut() }, + contentKey = { (badgeState, purchaseState, checkingPurchases) -> Triple(badgeState != null, purchaseState, checkingPurchases) } + ) { (badgeState, purchaseState, checkingPurchases) -> if (badgeState != null) { BadgesYourBadgeView(badgeState, modalManager) + } else if (purchaseState != null) { + // holds the purchase screens' slot, so a consumable cannot be bought twice + BadgesPurchaseStateView(purchaseState.title, purchaseState.message, BadgeStore.creditError(chatModel.currentUser.value?.userId), onDismiss = close) + } else if (checkingPurchases) { + BadgesPurchaseStateView(MR.strings.badges_checking_purchases_title, null, onDismiss = close) } else { - BadgesSupportSimplexView(modalManager) + BadgesSupportSimplexView(modalManager, unwindToDepth) } } } } +fun currentShownBadge(): BadgeState? { + if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return null + val badgeState = BadgeModel.badgeState.value + return if (badgeState != null && badgeState.shown) badgeState else null +} + +// The badges modal shows the purchase state itself, so screens pushed over it have to go once a purchase +// appears. Only the top one is composed, so this runs there, closing them in one effect because a close per +// recomposition restarts showInView's transition, and stopping on a depth because closeModal defers removal. +// TODO [badges] ModalManager has no close-above and records no parentage; with either, the depth goes away. +@Composable +fun CloseWhenSupportGivesWay(modalManager: ModalManager, unwindToDepth: Int) { + val gaveWay = BadgeStore.purchaseState(chatModel.currentUser.value?.userId) != null || currentShownBadge() != null + LaunchedEffect(gaveWay) { + if (gaveWay) while (modalManager.openModalCount() > unwindToDepth) modalManager.closeModal() + } +} + // ModalManager.end, not start: every caller is in the chat, which on desktop is the right pane fun openBadgesView() { - ModalManager.end.showCustomModal { close -> BadgesView(ModalManager.end, close) } + ModalManager.end.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.end, close) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt index c2ca7d7f10..f6f6ecb12d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt @@ -34,7 +34,7 @@ import chat.simplex.common.views.helpers.badgeTypeName import chat.simplex.common.views.helpers.openVerifiedSimplexUri import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.views.usersettings.simplexTeamUri -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt index 47a4d3b152..2a7f1034df 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt @@ -20,7 +20,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.BannerDismissButton import chat.simplex.common.views.chatlist.bannerCard import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SupportSimpleXBanner( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt index 563f4c3b83..70565fedb4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.ProfileImage import chat.simplex.common.views.usersettings.ProfilePreview -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Clock // Spec: spec/services/calls.md#IncomingCallAlertView diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 6cd0ba95ba..86e826e946 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -3,7 +3,7 @@ package chat.simplex.common.views.call import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -56,6 +56,7 @@ enum class CallState { AnswerReceived, Negotiated, Connected, + Reconnecting, Ended; val text: String get() = when(this) { @@ -67,6 +68,7 @@ enum class CallState { AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation) Negotiated -> generalGetString(MR.strings.callstate_connecting) Connected -> generalGetString(MR.strings.callstate_connected) + Reconnecting -> generalGetString(MR.strings.callstate_reconnecting) Ended -> generalGetString(MR.strings.callstate_ended) } } @@ -91,7 +93,7 @@ sealed class WCallCommand { @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() - @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand() + @Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallCommand() @Serializable @SerialName("media") data class Media(val source: CallMediaSource, val enable: Boolean): WCallCommand() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand() @@ -104,7 +106,7 @@ sealed class WCallResponse { @Serializable @SerialName("capabilities") data class Capabilities(val capabilities: CallCapabilities): WCallResponse() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse() @Serializable @SerialName("answer") data class Answer(val answer: String, val iceCandidates: String): WCallResponse() - @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse() + @Serializable @SerialName("ice") data class Ice(val iceCandidates: String, val session: String? = null): WCallResponse() @Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse() @Serializable @SerialName("peerMedia") data class PeerMedia(val source: CallMediaSource, val enabled: Boolean): WCallResponse() @@ -116,7 +118,7 @@ sealed class WCallResponse { @Serializable data class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession) @Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String) -@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String) +@Serializable data class WebRTCExtraInfo(val rtcSession: String? = null, val rtcIceCandidates: String) @Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities) @Serializable data class RcvCallInvitation( val remoteHostId: Long?, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index b35e828962..82575984f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -43,7 +43,7 @@ import chat.simplex.common.views.chat.group.ChatTTLOption import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.chatlist.updateChatSettings import chat.simplex.common.views.newchat.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -791,8 +791,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex val linkMode = chatModel.simplexLinkMode.value if (descr == null) { if (short != null) { + val parsed = parseToMarkdownText(short) MarkdownText( - short, parseToMarkdown(short), toggleSecrets = true, style = style, maxLines = 4, + short, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4, overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) } @@ -801,8 +802,9 @@ fun ProfileDescriptionText(shortDescr: String?, description: String?, style: Tex val truncated = firstLine.length > 100 val multiline = descr.length > firstLine.length if (short == null && !truncated && !multiline) { + val parsed = parseToMarkdownText(descr) MarkdownText( - descr, parseToMarkdown(descr), toggleSecrets = true, style = style, maxLines = 4, + descr, parsed?.formattedText, parsed?.markdownBlocks, toggleSecrets = true, style = style, maxLines = 4, overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode ) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 50f5e35462..0c7f1937f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.views.badges.openBadgesView import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.newchat.noShownBadge import chat.simplex.common.views.usersettings.networkAndServers.serverHostname -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.serialization.encodeToString @@ -52,17 +52,19 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools val selection = remember { mutableStateOf(CIInfoTab.History) } @Composable - fun TextBubble(text: String, formattedText: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { + fun TextBubble(text: String, formattedText: List?, markdownBlocks: List?, sender: String?, showMenu: MutableState, mentions: Map? = null, userMemberId: String? = null, ) { if (text != "") { MarkdownText( - text, if (text.isEmpty()) emptyList() else formattedText, + text, if (text.isEmpty()) emptyList() else formattedText, markdownBlocks, sender = sender, mentions = mentions, userMemberId = userMemberId, senderBold = true, toggleSecrets = true, linkMode = SimplexLinkMode.DESCRIPTION, uriHandler = uriHandler, - onLinkLongClick = { showMenu.value = true } + onLinkLongClick = { showMenu.value = true }, + blockLayout = true, + cardColor = if (sent) appColors.sentQuote else appColors.receivedQuote ) } else { Text( @@ -85,7 +87,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, ciVersion.formattedText, sender = null, showMenu = showMenu, mentions = ci.mentions, + TextBubble(text, ciVersion.formattedText, ciVersion.markdownBlocks, sender = null, showMenu = showMenu, mentions = ci.mentions, userMemberId = when { chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null @@ -137,7 +139,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools .onRightClick { showMenu.value = true } ) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - TextBubble(text, qi.formattedText, sender = qi.sender(null), showMenu) + TextBubble(text, qi.formattedText, qi.markdownBlocks, sender = qi.sender(null), showMenu) } } Row(Modifier.padding(start = 12.dp, top = 3.dp, bottom = 16.dp)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 256d1be180..9d88ac75ac 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -44,7 +44,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.platform.AudioPlayer import chat.simplex.common.views.newchat.ContactConnectionInfoView import chat.simplex.common.views.newchat.alertProfileImageSize -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -119,6 +119,20 @@ fun ChatView( } chat } } + if (chatsCtx.secondaryContextFilter == null) { + // cleared when the view is gone rather than on back: Android keeps it on screen while it slides out, + // and by then a channel being created may already be using this state + DisposableEffect(Unit) { + onDispose { + if (chatModel.chatId.value == null && chatModel.creatingChannelId.value == null) { + chatModel.groupMembers.value = emptyList() + chatModel.groupMembersIndexes.value = emptyMap() + chatModel.membersLoaded.value = false + ChannelRelaysModel.reset() + } + } + } + } val user = chatModel.currentUser.value val chatInfo = activeChat.value?.chatInfo if (chat == null || chatInfo == null || user == null) { @@ -143,7 +157,7 @@ fun ChatView( ) } val attachmentOption = rememberSaveable { mutableStateOf(null) } - val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden, skipHalfExpanded = true) val scope = rememberCoroutineScope() val selectedChatItems = rememberSaveable { mutableStateOf(null as Set?) } val showCommandsMenu = rememberSaveable { mutableStateOf(false) } @@ -382,10 +396,6 @@ fun ChatView( hideKeyboard(view) AudioPlayer.stop() chatModel.chatId.value = null - chatModel.groupMembers.value = emptyList() - chatModel.groupMembersIndexes.value = emptyMap() - chatModel.membersLoaded.value = false - ChannelRelaysModel.reset() }, info = { if (ModalManager.end.hasModalsOpen()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt index 26b1fce741..5f7aec3dd6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt @@ -25,7 +25,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.chat.group.* import chat.simplex.common.views.chat.item.sendCommandMsg import chat.simplex.common.views.helpers.commandMenuAnimSpec -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt index 14edea3ed6..06a8d529a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeChatLinkView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.model.MsgChatLink import chat.simplex.common.ui.theme.appColors import chat.simplex.common.views.helpers.ProfileImage import dev.icerock.moko.resources.compose.painterResource -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ComposeChatLinkView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt index 106c975226..c10e28ce11 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt @@ -18,7 +18,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.views.chatlist.acceptContactRequest import chat.simplex.common.views.chatlist.rejectContactRequest import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt index 21799ff820..015308fe22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index 6bfa023117..aa3c4a2683 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.views.chat.group.removeMember import chat.simplex.common.views.chat.group.removeMemberDialog import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt index b7be49055e..80940bf631 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.IncognitoOptionImage import chat.simplex.common.views.usersettings.IncognitoView -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt new file mode 100644 index 0000000000..ff8482ed7b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt @@ -0,0 +1,14 @@ +package chat.simplex.common.views.chat + +import androidx.compose.runtime.* +import androidx.compose.ui.text.TextRange +import kotlin.math.min + +@Composable +expect fun ComposeEmojiButton(pickerShown: MutableState, enabled: Boolean, onEmoji: (String) -> Unit) + +fun ComposeMessage.insertingEmoji(emoji: String): ComposeMessage { + val start = min(selection.min, text.length) + val end = min(selection.max, text.length) + return ComposeMessage(text.replaceRange(start, end, emoji), TextRange(start + emoji.length)) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt index 26e069c739..c5a21dfa37 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeFileView.kt @@ -12,7 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt index 97b6f9afda..c6ce37a9ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeImageView.kt @@ -14,7 +14,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.platform.base64ToBitmap -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.UploadContent diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index c81149fcb1..357a010d2c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -42,7 +42,7 @@ import chat.simplex.common.views.newchat.RelayProgressIndicator import chat.simplex.common.views.newchat.RelayStatusIndicator import chat.simplex.common.views.newchat.noShownBadge import chat.simplex.common.views.newchat.relayDisplayName -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged @@ -454,6 +454,7 @@ fun ComposeView( val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember(MaterialTheme.colors.isLight) { mutableStateOf(smallFont) } val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } + val emojiPickerShown = rememberSaveable { mutableStateOf(false) } AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } } suspend fun fetchAndUpdateLinkPreview(url: String) { @@ -1278,6 +1279,17 @@ fun ComposeView( val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason((ownerRelayState?.noActiveRelays ?: subscriberRelayState?.noActiveRelays) == true)) val sendMsgEnabled = rememberUpdatedState(userCantSendReason.value == null) val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv) + val emojiEnabled = sendMsgEnabled.value && composeState.value.preview !is ComposePreview.VoicePreview + LaunchedEffect(emojiEnabled) { + if (!emojiEnabled) emojiPickerShown.value = false + } + KeyChangeEffect(emojiPickerShown.value) { + if (!emojiPickerShown.value && emojiEnabled) focusRequester?.requestFocus() + } + + fun onEmoji(emoji: String) { + if (!composeState.value.inProgress) onMessageChange(composeState.value.message.insertingEmoji(emoji)) + } @Composable fun CommandsButton() { @@ -1332,7 +1344,7 @@ fun ComposeView( } @Composable - fun AttachmentAndCommandsButtons() { + fun ComposeActionButtons() { val cInfo = chat.chatInfo Row( Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), @@ -1346,6 +1358,7 @@ fun ComposeView( if (showAttachment) { AttachmentButton() } + ComposeEmojiButton(emojiPickerShown, emojiEnabled, ::onEmoji) } } @@ -1784,7 +1797,7 @@ fun ComposeView( ContextSendMessageToConnect(generalGetString(MR.strings.compose_send_direct_message_to_connect)) Divider() Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - AttachmentAndCommandsButtons() + ComposeActionButtons() SendMsgView_( disableSendButton = disableSendButton, sendToConnect = { withApi { sendMemberContactInvitation() } } @@ -1839,7 +1852,7 @@ fun ComposeView( ) } else { Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - AttachmentAndCommandsButtons() + ComposeActionButtons() val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi -> if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner && chat.chatInfo.groupChatScope() == null) generalGetString(MR.strings.compose_view_broadcast) else null @@ -2153,7 +2166,8 @@ private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState? if (!gInfo.useRelays || gInfo.membership.memberRole != GroupMemberRole.Owner || gInfo.membership.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) ) return null - val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList() + if (ChannelRelaysModel.groupId.value != gInfo.groupId) return null + val relays = ChannelRelaysModel.groupRelays.toList() if (relays.isEmpty()) return OwnerRelayState(emptyList(), 0, 0, 0, true) val relayMembers = relays.map { relay -> relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId } @@ -2186,6 +2200,7 @@ private fun subscriberRelayState(chat: Chat, chatModel: ChatModel): SubscriberRe val connectedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connStatus == ConnStatus.Ready && it.activeConn?.connFailedErr == null } val failedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connFailedErr != null } val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size + if (total == 0 && !chatModel.membersLoaded.value) return null val noActiveRelays = connectedCount == 0 && (removedCount + failedCount) == total return SubscriberRelayState(hostnames, relayMembers, connectedCount, removedCount, failedCount, total, noActiveRelays) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt index b070dce1d1..29c171b00f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.model.durationText import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.AudioPlayer -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.flow.distinctUntilChanged @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index 0276727ccc..b7adec1add 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -21,7 +21,7 @@ import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index e681c4fed7..7f92ca0651 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.views.chat.item.* import chat.simplex.common.model.* import chat.simplex.common.platform.getLoadedFilePath import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.datetime.Clock @@ -57,7 +57,7 @@ fun ContextItemView( } } else null MarkdownText( - contextItem.text, contextItem.formattedText, + contextItem.text, contextItem.formattedText, contextItem.markdownBlocks, sender = null, toggleSecrets = false, maxLines = lines, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt index 428d4b1b8f..ea0ef11aef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt @@ -9,7 +9,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 03d0b99854..3f3278ee58 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -17,7 +17,7 @@ import chat.simplex.common.platform.BackHandler import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.* import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index b116201d87..8205071225 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -24,7 +24,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatItem import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt index d3e3ffbe61..7915aedca5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SimplexNameView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index db31c05dce..e04f75d799 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -37,9 +37,9 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.chat.item.itemPrefixText -import chat.simplex.common.views.chat.item.itemSegmentDisplayText +import chat.simplex.common.views.chat.item.itemDisplay import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* @@ -267,23 +267,22 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple if (sel.first < prefix.length) { sb.append(prefix, sel.first, minOf(prefix.length, sel.last + 1)) } - val formattedText = ci.formattedText ?: run { + val parts = itemDisplay(ci, linkMode)?.parts ?: run { val start = (sel.first - prefix.length).coerceAtLeast(0).coerceAtMost(ci.text.length) val end = (sel.last + 1 - prefix.length).coerceAtMost(ci.text.length) if (start < end) sb.append(ci.text, start, end) return sb.toString() } var displayOffset = prefix.length - for (ft in formattedText) { - val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + for ((segDisplay, source) in parts) { val displayEnd = displayOffset + segDisplay.length val overlapStart = maxOf(displayOffset, sel.first) val overlapEnd = minOf(displayEnd, sel.last + 1) if (overlapStart < overlapEnd) { - if (ft.text.length == segDisplay.length) { - sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset) + if (source.length == segDisplay.length) { + sb.append(source, overlapStart - displayOffset, overlapEnd - displayOffset) } else { - sb.append(ft.text) + sb.append(source) } } displayOffset = displayEnd @@ -293,12 +292,11 @@ private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: Simple // Snaps a boundary offset to include full transformed segments. private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int { - val formattedText = ci.formattedText ?: return offset + val parts = itemDisplay(ci, linkMode)?.parts ?: return offset var displayOffset = itemPrefixText(ci).length - for (ft in formattedText) { - val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + for ((segDisplay, source) in parts) { val displayEnd = displayOffset + segDisplay.length - if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) { + if (offset > displayOffset && offset < displayEnd && source.length != segDisplay.length) { return if (expandRight) displayEnd else displayOffset } displayOffset = displayEnd @@ -425,18 +423,55 @@ private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { return idx } -class ItemSelection( +class TextSelection( val highlightRange: IntRange?, val positionModifier: Modifier, - val onTextLayoutResult: ((TextLayoutResult) -> Unit)? + val onTextLayoutResult: (TextLayoutResult) -> Unit ) +class SelectionText(var base: Int) { + var bounds: Rect? = null + var layout: TextLayoutResult? = null +} + +class ItemSelection( + private val highlightRange: IntRange?, + private val texts: MutableMap?, + private val bases: List +) { + fun text(index: Int): TextSelection? { + val texts = texts ?: return null + val base = bases.getOrNull(index) ?: return null + val t = texts.getOrPut(index) { SelectionText(base) } + t.base = base + val highlight = highlightRange?.let { (it.first - base).coerceAtLeast(0) .. (it.last - base) }?.takeUnless { it.isEmpty() } + return TextSelection( + highlight, + Modifier.onGloballyPositioned { + val pos = it.positionInWindow() + t.bounds = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) + }, + { t.layout = it } + ) + } +} + +private class TextHit(val text: SelectionText, val bounds: Rect, val layout: TextLayoutResult, val offset: Int) + +private fun textAt(texts: Collection, x: Float, y: Float): TextHit? = + texts.mapNotNull { t -> + val b = t.bounds ?: return@mapNotNull null + val l = t.layout?.takeIf { it.layoutInput.text.isNotEmpty() } ?: return@mapNotNull null + Triple(t, b, l) + }.minByOrNull { (_, b, _) -> maxOf(b.top - y, 0f, y - b.bottom) * 4 + maxOf(b.left - x, 0f, x - b.right) } + ?.let { (t, b, l) -> TextHit(t, b, l, l.getOffsetForPosition(Offset(x - b.left, y - b.top))) } + // Sets up selection tracking for a text item: anchor/focus offset resolution, // highlight range computation, and position/layout result capture. @Composable -fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection { - val boundsState = remember { mutableStateOf(null) } - val layoutResultState = remember { mutableStateOf(null) } +fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean, bases: List): ItemSelection { + val texts = remember { HashMap() } + texts.keys.retainAll { it < bases.size } if (selectionManager != null && selectionIndex >= 0 && !isLive) { val isAnchor = remember(selectionIndex) { @@ -444,12 +479,8 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, } LaunchedEffect(isAnchor.value) { if (!isAnchor.value) return@LaunchedEffect - val bounds = boundsState.value ?: return@LaunchedEffect - val layout = layoutResultState.value ?: return@LaunchedEffect - val offset = layout.getOffsetForPosition( - Offset(selectionManager.anchorWindowX - bounds.left, selectionManager.anchorWindowY - bounds.top) - ) - selectionManager.setAnchorOffset(offset) + val hit = textAt(texts.values, selectionManager.anchorWindowX, selectionManager.anchorWindowY) ?: return@LaunchedEffect + selectionManager.setAnchorOffset(hit.text.base + hit.offset) } val isFocus = remember(selectionIndex) { @@ -459,10 +490,9 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, LaunchedEffect(Unit) { snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX } .collect { (py, px) -> - val bounds = boundsState.value ?: return@collect - val layout = layoutResultState.value ?: return@collect - val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) - val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1)) + val hit = textAt(texts.values, px, py) ?: return@collect + val bounds = hit.bounds + val charBox = hit.layout.getBoundingBox(hit.offset.coerceIn(0, hit.layout.layoutInput.text.length - 1)) val ls = selectionManager.listState?.value val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex } val charRect = if (ls != null && itemInfo != null) { @@ -474,7 +504,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, bottom = bounds.top + charBox.bottom - itemWindowY ) } else Rect.Zero - selectionManager.updateFocusOffset(offset, charRect) + selectionManager.updateFocusOffset(hit.text.base + hit.offset, charRect) } } } @@ -484,18 +514,7 @@ fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value } else null - val positionModifier = if (selectionManager != null) { - Modifier.onGloballyPositioned { - val pos = it.positionInWindow() - boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) - } - } else Modifier - - val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) { - { layoutResultState.value = it } - } else null - - return ItemSelection(highlightRange, positionModifier, onTextLayoutResult) + return ItemSelection(highlightRange, if (selectionManager != null) texts else null, bases) } // Sets up full-item selection for emoji items (no character-level tracking). diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt index 880c15216e..3610147804 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun VerifyCodeView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 7223f69f98..731b125955 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt index 95ce003caa..f67efc0202 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.chatRelayDisplayName import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt index 128747c4fa..ea841acc86 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.views.chat.ownersContributorsCountStr import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ModalData.ChannelMembersView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt index 60cc19bb1b..951b7b2c35 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -20,7 +20,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt index 18a944f671..7f40458f42 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelWebPageView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index d14da9dc33..973b684d38 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -49,7 +49,7 @@ import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.database.TtlOptions import chat.simplex.common.views.newchat.SimpleXLinkQRCode -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index f6f19e9d72..9f02d0fefb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -24,7 +24,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun GroupLinkView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 885d3de4e6..ac1c6915b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -36,7 +36,7 @@ import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chatlist.openDirectChat -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.datetime.Clock import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt index 81e7d80da3..6dcc6e9a4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt @@ -16,7 +16,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.WarningOrange import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt index 2a5f9df8c1..5f0fed040e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch import kotlin.text.CharCategory.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index f04d63e2d2..bd3af10c37 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -21,7 +21,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index 6e91ad92d6..c93179edd7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* import java.net.URI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt index 2cc2402c0a..0c272ae98d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt @@ -9,7 +9,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt index 544af8ed7e..ef333a8826 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 0da3c46abb..8e0b3321b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index b1a9ec1654..7948f23367 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 927e9940b5..ef847c93cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -28,7 +28,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* private const val maxByteCount = 1200 @@ -146,13 +146,16 @@ private fun TextPreview(text: String, linkMode: SimplexLinkMode, markdown: Boole val uriHandler = LocalUriHandler.current Column { SelectionContainer(Modifier.fillMaxWidth()) { + val parsed = if (markdown) remember(text) { parseToMarkdownText(text) } else null MarkdownText( text, - formattedText = if (markdown) remember(text) { parseToMarkdown(text) } else null, + formattedText = parsed?.formattedText, + markdownBlocks = parsed?.markdownBlocks, toggleSecrets = false, modifier = Modifier.fillMaxHeight().padding(horizontal = DEFAULT_PADDING), linkMode = linkMode, uriHandler = uriHandler, - style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp) + style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), + blockLayout = true ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt index d49f8526d5..1d50ba17e4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIBrokenComposableView.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIBrokenComposableView(alignment: Alignment) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index 744bcf7b66..d3cc76fb7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -12,7 +12,7 @@ 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 chat.simplex.res.* @Composable fun CICallItemView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt index 3c3e4baf49..23fce97a6f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatLinkHeader.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.ProfileImage -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt index 1a23dfc49a..27e605df4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIFeaturePreferenceView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index fb4a2cdaea..42cbff3a2f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -25,7 +25,7 @@ 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 chat.simplex.res.* import java.io.File import java.net.URI @@ -80,6 +80,7 @@ fun CIFileView( if (prohibited != null) { showProhibitedFileAlert(file, prohibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } } @@ -105,6 +106,8 @@ fun CIFileView( showFileErrorAlert(file.fileStatus.sndFileError) file.fileStatus is CIFileStatus.SndWarning -> showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) + isMarkdownFile(file) && file.loaded -> + showMarkdownFile(file) file.forwardingAllowed() -> { withLongRunningApi(slow = 600_000) { var filePath = getLoadedFilePath(file) @@ -183,21 +186,23 @@ fun CIFileView( val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) } val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() } - val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null + val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null && !isMarkdownFile(file)) getLoadedFilePath(file) else null if (loadedFilePath != null && file?.fileSource != null) { val encrypted = file.fileSource.cryptoArgs != null SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() }) } + val onClick: () -> Unit = { + if (appPlatform.isAndroid && loadedFilePath != null) { + showOpenSaveMenu.value = true + } else { + fileAction() + } + } + if (file != null) OpenWhenReceivedEffect(file, smallView, onClick) Row( Modifier .combinedClickable( - onClick = { - if (appPlatform.isAndroid && loadedFilePath != null) { - showOpenSaveMenu.value = true - } else { - fileAction() - } - }, + onClick = onClick, onLongClick = { showMenu.value = true } ) .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), @@ -239,6 +244,13 @@ fun CIFileView( // the core decides whether a received file is above the size the sender's badge allows fun fileSizeValid(file: CIFile): Boolean = file.fileProhibited == null +@Composable +fun OpenWhenReceivedEffect(file: CIFile, inChatList: Boolean, open: () -> Unit) { + KeyChangeEffect(file.loaded) { + if (chatModel.receivedFileToOpen(file, inChatList)) open() + } +} + fun showProhibitedFileAlert(file: CIFile, prohibited: FileProhibited) { val badgeIssue = when (prohibited.badgeStatus) { null, BadgeStatus.Active -> "" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 9b8393f66a..14ecd723b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay @Composable @@ -29,6 +29,7 @@ fun CIGroupInvitationView( memberRole: GroupMemberRole, showTimestamp: Boolean, chatIncognito: Boolean = false, + groupChat: Boolean = false, joinGroup: (Long, () -> Unit) -> Unit, timedMessagesTTL: Int? ) { @@ -111,6 +112,9 @@ fun CIGroupInvitationView( val secondaryColor = MaterialTheme.colors.secondary Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (groupChat) { + Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor) + } if (action) { Text(groupInvitationStr(), fontSize = 13.sp, lineHeight = 18.sp) Text( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index fc1c76074d..a49884beeb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.common.views.chat.chatViewScrollState -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlin.math.roundToInt @@ -173,6 +173,15 @@ fun CIImageView( return null } + val view = LocalMultiplatformView() + if (file != null) { + OpenWhenReceivedEffect(file, smallView) { + hideKeyboard(view) + ModalManager.fullscreen.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) + } + } + } Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) .then( @@ -222,6 +231,7 @@ fun CIImageView( if (file.fileProhibited != null) { showProhibitedFileAlert(file, file.fileProhibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } file.fileStatus is CIFileStatus.RcvAccepted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt index d297d5ef76..391ee88e2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIInvalidJSONView.kt @@ -14,7 +14,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.shareText import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIInvalidJSONView(json: String) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt index 2ade49b3fc..bbd7788f29 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CIMemberCreatedContactView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index 92f89ccad9..e318f406de 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.appPreferences import chat.simplex.common.ui.theme.isInDarkTheme -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.Clock @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index d58fd7553f..589b2a6fa6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -17,7 +17,7 @@ import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.appColors import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 8b4756c3ad..62e1b9f737 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.platform.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.* -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* @@ -70,6 +70,8 @@ fun CIVideoView( } } val f = filePath.value + val autoPlay = remember { mutableStateOf(false) } + if (file != null) OpenWhenReceivedEffect(file, smallView) { autoPlay.value = true } if (file != null && f != null) { val view = LocalMultiplatformView() val openFullscreen = { @@ -80,7 +82,6 @@ fun CIVideoView( } val uri = remember(filePath) { getAppFileUri(f.substringAfterLast(File.separator)) } - val autoPlay = remember { mutableStateOf(false) } val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) } val decrypted = uriDecrypted.value if (decrypted != null && smallView) { @@ -98,7 +99,7 @@ fun CIVideoView( if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted -> - receiveFileIfValidSize(file, receiveFile) + receiveFileIfValidSize(file, smallView, receiveFile) CIFileStatus.RcvAccepted -> when (file.fileProtocol) { FileProtocol.XFTP -> @@ -128,7 +129,7 @@ fun CIVideoView( DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) { - PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, smallView, receiveFile) } } } } @@ -156,22 +157,26 @@ private fun VideoViewEncrypted( ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } + val play: () -> Unit = { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + LaunchedEffect(autoPlay.value) { + if (autoPlay.value) play() + } Box { VideoPreviewImageView(defaultPreview, smallView = false, blurred = blurred, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(1f, onLongClick = onLongClick) } else if (!blurred.value) { - PlayButton(false, 1f, onLongClick = onLongClick) { - decryptionInProgress = true - withBGApi { - try { - uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() - autoPlay.value = uriUnencrypted.value != null - } finally { - decryptionInProgress = false - } - } - } + PlayButton(false, 1f, onLongClick = onLongClick, onClick = play) } DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(defaultDuration) }, remember { mutableStateOf(0L) }) } @@ -190,22 +195,26 @@ private fun SmallVideoViewEncrypted( ) { var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } + val play: () -> Unit = { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + LaunchedEffect(autoPlay.value) { + if (autoPlay.value) play() + } Box { VideoPreviewImageView(defaultPreview, smallView = true, blurred = blurred, onClick = if (decryptionInProgress) {{}} else openFullscreen, onLongClick = onLongClick) if (decryptionInProgress) { VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick) } else if (!file.showStatusIconInSmallView && !blurHidesMedia(true, blurred)) { - PlayButton(false, sizeMultiplier, onLongClick = onLongClick) { - decryptionInProgress = true - withBGApi { - try { - uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() - autoPlay.value = uriUnencrypted.value != null - } finally { - decryptionInProgress = false - } - } - } + PlayButton(false, sizeMultiplier, onLongClick = onLongClick, onClick = play) } } } @@ -240,7 +249,7 @@ private fun SmallVideoView( PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen) } } - LaunchedEffect(uri) { + LaunchedEffect(uri, autoPlay.value) { if (autoPlay.value) openFullscreen() } } @@ -272,7 +281,7 @@ private fun VideoView( player.stop() } val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } } - LaunchedEffect(uri) { + LaunchedEffect(uri, autoPlay.value) { if (autoPlay.value) play() } // Drop autoPlay only when show preview changes to prevent blinking of the view @@ -566,10 +575,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { private fun showDownloadButton(status: CIFileStatus?): Boolean = status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted -private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { +private fun receiveFileIfValidSize(file: CIFile, smallView: Boolean, receiveFile: (Long) -> Unit) { if (file.fileProhibited != null) { showProhibitedFileAlert(file, file.fileProhibited) } else { + chatModel.openWhenReceived(file, smallView) receiveFile(file.fileId) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index c3e1c92732..5aeefd9668 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.flow.* import kotlin.math.* @@ -87,7 +87,7 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, receiveFile) { + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, { chatModel.openWhenReceived(file, smallView); receiveFile(it) }) { AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } if (smallView) { @@ -95,6 +95,7 @@ fun CIVoiceView( AudioPlayer.stop() } } + OpenWhenReceivedEffect(file, smallView, play) } else if (smallView) { VoiceMsgIndicator(null, false, sent, hasText, null, null, false, sizeMultiplier, {}, {}, longClick, receiveFile) } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index c38bc1fe08..822aef9aa2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -34,7 +34,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.datetime.Clock @@ -720,11 +720,11 @@ fun ChatItemView( DeleteItemMenu() } is CIContent.RcvGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, groupChat = cInfo is ChatInfo.Group, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) DeleteItemMenu() } is CIContent.SndGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, groupChat = cInfo is ChatInfo.Group, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) DeleteItemMenu() } is CIContent.RcvDirectEventContent -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index e2e7f0bc69..ca370c1b52 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -25,7 +25,7 @@ import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.newchat.planAndConnect -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.math.ceil @@ -61,6 +61,7 @@ fun FramedItemView( MarkdownText( qi.text, qi.formattedText, + qi.markdownBlocks, toggleSecrets = true, maxLines = lines, overflow = TextOverflow.Ellipsis, @@ -457,11 +458,12 @@ fun CIMarkdownText( ) { val chatInfo = chat.chatInfo val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text - val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true) + val bases = remember(ci, linkMode) { itemDisplay(ci, linkMode)?.bases ?: listOf(0) } + val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true, bases) - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) { + Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { MarkdownText( - text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, + text, if (text.isEmpty()) emptyList() else ci.formattedText, if (text.isEmpty()) null else ci.markdownBlocks, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, mentions = ci.mentions, userMemberId = when { @@ -470,8 +472,9 @@ fun CIMarkdownText( }, uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, stripLink = stripLink, - selectionRange = selection.highlightRange, - onTextLayoutResult = selection.onTextLayoutResult + selection = selection, + blockLayout = true, + cardColor = if (ci.chatDir.sent) MaterialTheme.appColors.sentQuote else MaterialTheme.appColors.receivedQuote ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt index 08b6520dfa..9b378ea9ea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.model.RcvMsgError import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt new file mode 100644 index 0000000000..75a778e567 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt @@ -0,0 +1,248 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.takeOrElse +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun MarkdownBlocks( + formattedText: List, + blocks: List, + style: TextStyle, + modifier: Modifier, + cardColor: Color, + blockText: @Composable (index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier, softWrap: Boolean, last: Boolean) -> Unit, + metaLine: @Composable () -> Unit +) { + val roundness = remember { appPreferences.chatItemRoundness.state }.value.coerceIn(0f, 1f) + val cardShape = RoundedCornerShape(12.dp * roundness) + val lineColor = MaterialTheme.colors.secondary.copy(alpha = 0.35f) + val textColor = style.color.takeOrElse { MaterialTheme.colors.onSurface } + val fontSize = style.fontSize.takeOrElse { 16.sp } + val lineHeight = style.lineHeight.takeOrElse { 22.sp } + val starts = remember(blocks) { blocks.runningFold(0) { n, b -> n + b.textRanges.size } } + val lastIsText = when (blocks.lastOrNull()) { + is MarkdownBlock.Paragraph, is MarkdownBlock.Heading, is MarkdownBlock.Quote, is MarkdownBlock.ItemList -> true + else -> false + } + @Composable + fun BlockText(index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier = Modifier, softWrap: Boolean = true) { + blockText(index, range, style, modifier, softWrap, lastIsText && index == starts.last() - 1) + } + Column(modifier) { + blocks.forEachIndexed { k, block -> + if (k > 0 && paragraphBreak(formattedText, blocks[k - 1].end, block.start)) { + Spacer(Modifier.height(with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() })) + } + val start = starts[k] + when (block) { + is MarkdownBlock.Paragraph -> BlockText(start, block.content, style) + is MarkdownBlock.Heading -> { + val scale = when (block.level) { + 1 -> 1.5f + 2 -> 1.3f + 3 -> 1.15f + else -> 1f + } + BlockText(start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold)) + } + is MarkdownBlock.Quote -> Row(Modifier.height(IntrinsicSize.Min)) { + Box(Modifier.width(4.dp).fillMaxHeight().clip(RoundedCornerShape(2.dp)).background(textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f))) + Spacer(Modifier.width(10.dp)) + BlockText(start, block.content, style.copy(color = textColor.copy(alpha = 0.7f))) + } + is MarkdownBlock.ItemList -> listEntries(block.items, 0).forEachIndexed { j, (entry, level) -> + Row(Modifier.padding(start = 20.dp * level)) { + Text(entry.number ?: "•", Modifier.widthIn(min = 14.dp), style = style) + Spacer(Modifier.width(6.dp)) + BlockText(start + j, entry.content, style, Modifier.weight(1f, fill = false)) + } + } + is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content)) { m -> + BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = fontSize * 0.875f, lineHeight = lineHeight * 0.9f), m, softWrap = false) + } + is MarkdownBlock.Table -> { + val tableRows = listOf(block.header) + block.rows + val rowStarts = tableRows.runningFold(start) { n, row -> n + row.size } + Box(Modifier.horizontalScroll(rememberScrollState())) { + TableGrid( + block.columns, tableRows.map { it.size }, cardColor, MaterialTheme.colors.secondary.copy(alpha = 0.1f), lineColor, + Modifier.clip(cardShape).border(1.dp, lineColor, cardShape) + ) { + tableRows.forEachIndexed { r, row -> + row.forEachIndexed { c, cell -> + BlockText( + rowStarts[r] + c, cell, if (r == 0) style.copy(fontWeight = FontWeight.Bold) else style, + Modifier.padding(horizontal = 12.dp, vertical = 4.dp), softWrap = false + ) + } + } + } + } + } + is MarkdownBlock.Rule -> Box(Modifier.padding(vertical = 6.dp).fillMaxWidth().height(1.dp).background(lineColor)) + } + } + if (!lastIsText) metaLine() + } +} + +private val MarkdownBlock.start: Int get() = when (this) { + is MarkdownBlock.Rule -> mark.from + else -> textRanges.firstOrNull()?.range?.from ?: 0 +} + +private val MarkdownBlock.end: Int get() = when (this) { + is MarkdownBlock.Rule -> mark.to + else -> textRanges.lastOrNull()?.range?.to ?: 0 +} + +private fun paragraphBreak(fts: List, from: Int, to: Int): Boolean = + (from.coerceIn(0, fts.size) until to.coerceIn(0, fts.size)).any { (fts[it].format as? Format.Markup)?.token is BlockToken.ParagraphBreak } + +private fun codeText(fts: List, r: SegmentRange): String = + (r.from.coerceIn(0, fts.size) until r.to.coerceIn(0, fts.size)).joinToString("") { i -> + when (val f = fts[i].format) { + is Format.Markup -> f.token.drawnText + is Format.Escape -> "" + else -> fts[i].text + } + } + +@Composable +private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, content: @Composable (Modifier) -> Unit) { + var tapped by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + val clipboard = LocalClipboardManager.current + Box( + Modifier + .fillMaxWidth() + .clip(shape) + .background(color) + .hoverable(interactionSource) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + if (waitForUpOrCancellation() != null) tapped = !tapped + } + } + ) { + content(Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp)) + if (hovered || tapped) { + Box(Modifier.matchParentSize(), contentAlignment = Alignment.TopEnd) { + Box( + Modifier + .padding(4.dp) + .size(28.dp) + .clip(RoundedCornerShape(8.dp * roundness)) + .background(color) + .clickable { + clipboard.setText(AnnotatedString(copyText)) + showToast(generalGetString(MR.strings.copied)) + }, + contentAlignment = Alignment.Center + ) { + Icon(painterResource(MR.images.ic_content_copy), stringResource(MR.strings.copy_verb), Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + } + } + } + } +} + +private class TableGridLines { + var xs: List = emptyList() + var ys: List = emptyList() +} + +@Composable +private fun TableGrid( + columns: List, + rowSizes: List, + headerColor: Color, + stripeColor: Color, + lineColor: Color, + modifier: Modifier, + content: @Composable () -> Unit +) { + val lines = remember { TableGridLines() } + Layout( + content, + modifier.drawBehind { + val xs = lines.xs + val ys = lines.ys + if (ys.size < 2) return@drawBehind + drawRect(headerColor, Offset.Zero, Size(size.width, ys[1].toFloat())) + for (r in 2 until ys.size - 1 step 2) { + drawRect(stripeColor, Offset(0f, ys[r].toFloat()), Size(size.width, (ys[r + 1] - ys[r]).toFloat())) + } + val stroke = 1.dp.toPx() + xs.drop(1).dropLast(1).forEach { x -> drawLine(lineColor, Offset(x.toFloat(), 0f), Offset(x.toFloat(), size.height), stroke) } + ys.drop(1).dropLast(1).forEach { y -> drawLine(lineColor, Offset(0f, y.toFloat()), Offset(size.width, y.toFloat()), stroke) } + } + ) { measurables, _ -> + val cells = rowSizes.flatMapIndexed { r, n -> List(n) { c -> r to c } }.take(measurables.size) + val widths = IntArray(maxOf(columns.size, rowSizes.maxOrNull() ?: 0)) + cells.forEachIndexed { k, (_, c) -> widths[c] = maxOf(widths[c], measurables[k].maxIntrinsicWidth(Constraints.Infinity)) } + val placeables = cells.mapIndexed { k, (_, c) -> measurables[k].measure(Constraints(maxWidth = widths[c])) } + val heights = IntArray(rowSizes.size) + placeables.forEachIndexed { k, p -> heights[cells[k].first] = maxOf(heights[cells[k].first], p.height) } + val xs = widths.toList().runningFold(0) { a, w -> a + w } + val ys = heights.toList().runningFold(0) { a, h -> a + h } + lines.xs = xs + lines.ys = ys + layout(xs.last(), ys.last()) { + placeables.forEachIndexed { k, p -> + val (r, c) = cells[k] + val free = widths[c] - p.width + val x = xs[c] + when (columns.getOrNull(c)) { + TableAlign.Right -> free + TableAlign.Center -> free / 2 + else -> 0 + } + p.place(x, ys[r]) + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt new file mode 100644 index 0000000000..189e69a925 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownFileView.kt @@ -0,0 +1,284 @@ +package chat.simplex.common.views.chat.item + +import SectionBottomSpacer +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.networkAndServers.documentMarkdownStyle +import chat.simplex.res.* +import com.mikepenz.markdown.compose.MarkdownElement +import com.mikepenz.markdown.m2.Markdown +import com.mikepenz.markdown.model.ImageData +import com.mikepenz.markdown.model.ImageTransformer +import com.mikepenz.markdown.model.State +import com.mikepenz.markdown.model.markdownAnnotator +import com.mikepenz.markdown.model.parseMarkdownFlow +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import org.intellij.markdown.MarkdownElementTypes +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.getTextInNode +import org.intellij.markdown.flavours.gfm.GFMElementTypes +import java.io.ByteArrayInputStream +import java.io.File +import java.net.URI +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction + +private const val DATA_IMAGE = "data:image/" +private const val MAX_MARKDOWN_VIEW_SIZE: Long = 1_048_576 +private const val MAX_MARKDOWN_LINE_LENGTH = 15_000 +private const val MAX_MARKDOWN_DEPTH = 32 +private const val MAX_MARKDOWN_TABLE_ROWS = 200 +private const val MAX_MARKDOWN_IMAGE_PIXELS = 2160L * 4320 + +private val controlChar = Regex("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]") + +private val bidiControlChar = Regex("[\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u2069]") + +private val invisibleCharMarkerStyle = SpanStyle(color = WarningOrange) + +private class MarkdownDocument(val lines: List, val formatted: State.Success?, val hasBidiControls: Boolean) + +fun isMarkdownFile(file: CIFile): Boolean = + file.fileSize <= MAX_MARKDOWN_VIEW_SIZE && file.fileName.substringAfterLast('.', "").lowercase() in listOf("md", "markdown") + +fun showMarkdownFile(file: CIFile) { + withLongRunningApi(slow = 600_000) { + var filePath = getLoadedFilePath(file) + if (chatModel.connectedToRemote() && filePath == null) { + file.loadRemoteFile(true) + filePath = getLoadedFilePath(file) + } + if (filePath == null) { + AlertManager.shared.showAlertMsg(MR.strings.file_not_found) + return@withLongRunningApi + } + val bytes = try { + val cryptoArgs = file.fileSource?.cryptoArgs + if (cryptoArgs != null) readCryptoFile(filePath, cryptoArgs) else File(filePath).readBytes() + } catch (e: Exception) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.message) + return@withLongRunningApi + } + val text = decodedText(bytes) + val lines = text.lines() + val formatted = if (lines.all { it.length <= MAX_MARKDOWN_LINE_LENGTH }) formattedWithinLimits(text) else null + val document = MarkdownDocument( + lines.flatMap { line -> line.chunked(MAX_MARKDOWN_LINE_LENGTH).ifEmpty { listOf("") } }, + formatted, + bidiControlChar.containsMatchIn(text) + ) + ModalManager.center.showModal(endButtons = { + if (formatted != null) MarkdownViewSwitch() + MarkdownFileSaveButton(file, bytes) + }) { + MarkdownFileView(file.fileName, document) + } + } +} + +private fun decodedText(bytes: ByteArray): String = + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + .replaceWith("?") + .decode(ByteBuffer.wrap(bytes)) + .toString() + .replace(controlChar, "?") + +private suspend fun formattedWithinLimits(text: String): State.Success? = + (parseMarkdownFlow(text).first { it !is State.Loading } as? State.Success)?.takeIf { treeWithinLimits(it.node) } + +private fun treeWithinLimits(root: ASTNode): Boolean { + val nodes = ArrayDeque(listOf(root to 1)) + while (nodes.isNotEmpty()) { + val (node, depth) = nodes.removeLast() + if (depth > MAX_MARKDOWN_DEPTH) return false + if (node.type == GFMElementTypes.TABLE && node.children.count { it.type == GFMElementTypes.ROW } > MAX_MARKDOWN_TABLE_ROWS) return false + node.children.forEach { nodes.addLast(it to depth + 1) } + } + return true +} + +@Composable +private fun MarkdownViewSwitch() { + val textView = remember { appPrefs.markdownFileTextView.state } + IconButton({ appPrefs.markdownFileTextView.set(!textView.value) }) { + Icon( + painterResource(if (textView.value) MR.images.ic_article else MR.images.ic_code), + stringResource(if (textView.value) MR.strings.markdown_formatted_view else MR.strings.markdown_text_view), + tint = MaterialTheme.colors.primary + ) + } +} + +@Composable +private fun MarkdownFileSaveButton(file: CIFile, bytes: ByteArray) { + val saveFileLauncher = rememberFileChooserLauncher(false, bytes) { to: URI? -> + if (to != null) copyBytesToFile(ByteArrayInputStream(bytes), to) {} + } + IconButton({ withLongRunningApi { saveFileLauncher.launch(file.fileName) } }) { + Icon(painterResource(MR.images.ic_download), stringResource(MR.strings.save_verb), tint = MaterialTheme.colors.primary) + } +} + +@Composable +private fun MarkdownFileView(fileName: String, document: MarkdownDocument) { + val textView = remember { appPrefs.markdownFileTextView.state } + val formatted = document.formatted + if (textView.value || formatted == null) { + SelectionContainer { + MarkdownFileColumn(fileName, document.hasBidiControls) { + items(document.lines) { line -> + Text(remember(line) { withInvisibleCharMarkers(line) }, Modifier.padding(horizontal = DEFAULT_PADDING), fontFamily = FontFamily.Monospace) + } + } + } + } else { + FormattedMarkdown(fileName, formatted, document.hasBidiControls) + } +} + +private fun withInvisibleCharMarkers(line: String): AnnotatedString = buildAnnotatedString { + var start = 0 + invisibleChar.findAll(line).forEach { + append(line.substring(start, it.range.first)) + withStyle(invisibleCharMarkerStyle) { append(unicodeMarker(it.value)) } + start = it.range.last + 1 + } + append(line.substring(start)) +} + +@Composable +private fun FormattedMarkdown(fileName: String, formatted: State.Success, hasBidiControls: Boolean) { + val style = documentMarkdownStyle(h1 = MaterialTheme.typography.h2.copy(fontSize = 26.sp, fontWeight = FontWeight.Bold)) + val codeSpanStyle = style.typography.inlineCode.toSpanStyle().copy(background = style.colors.inlineCodeBackground) + val annotator = remember(codeSpanStyle) { + markdownAnnotator { content, child -> + when (child.type) { + MarkdownElementTypes.CODE_SPAN -> { + withStyle(codeSpanStyle) { append(" ${child.getTextInNode(content).trim('`')} ") } + true + } + MarkdownElementTypes.IMAGE -> + if (imageLink(content, child)?.startsWith(DATA_IMAGE) == true) { + false + } else { + append(child.getTextInNode(content).toString()) + true + } + else -> false + } + } + } + val imageTransformer = remember { DataImageTransformer() } + val parentUriHandler = LocalUriHandler.current + CompositionLocalProvider(LocalUriHandler provides remember { markdownFileUriHandler(parentUriHandler) }) { + Markdown( + formatted, + colors = style.colors, + typography = style.typography, + padding = style.padding, + imageTransformer = imageTransformer, + annotator = annotator, + components = style.components, + success = { state, components, _ -> + MarkdownFileColumn(fileName, hasBidiControls) { + items(state.node.children) { node -> + Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { + MarkdownElement(node, components, state.content, skipLinkDefinition = state.linksLookedUp) + } + } + } + } + ) + } +} + +@Composable +private fun MarkdownFileColumn(fileName: String, hasBidiControls: Boolean, content: LazyListScope.() -> Unit) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + LazyColumnWithScrollBar( + contentPadding = PaddingValues( + top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp else topPaddingToContent(false) + ) + ) { + item { AppBarTitle(fileName) } + if (hasBidiControls) { + item { + Text(stringResource(MR.strings.markdown_file_bidi_warning), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING), color = WarningOrange) + } + } + content() + item { SectionBottomSpacer() } + } +} + +private fun imageLink(content: String, image: ASTNode): String? = + image.children.firstOrNull { it.type == MarkdownElementTypes.INLINE_LINK } + ?.children?.firstOrNull { it.type == MarkdownElementTypes.LINK_DESTINATION } + ?.getTextInNode(content)?.toString() + +private fun markdownFileUriHandler(parentUriHandler: UriHandler): UriHandler = object: UriHandler { + override fun openUri(uri: String) { + val format = parseToMarkdown(uri)?.singleOrNull()?.format + when { + format is Format.SimplexLink -> parentUriHandler.openVerifiedSimplexUri(format.simplexUri) + format is Format.Uri && (uri.startsWith("https://") || uri.startsWith("http://")) -> openBrowserAlert(uri, parentUriHandler) + else -> showInvalidLinkAlert(uri) + } + } +} + +private class DataImageTransformer : ImageTransformer { + private var pixels = MAX_MARKDOWN_IMAGE_PIXELS + private val images = mutableMapOf() + + @Composable + override fun transform(link: String): ImageData? { + val width = with(LocalDensity.current) { LocalWindowWidth().roundToPx() } + val bitmap by produceState(null, link) { + value = withContext(Dispatchers.Default) { decodedImage(link, width) } + } + return remember(bitmap) { bitmap?.let { ImageData(BitmapPainter(it)) } } + } + + private fun decodedImage(link: String, width: Int): ImageBitmap? = synchronized(this) { + if (link !in images) images[link] = imageWithinBudget(link, width) + images[link] + } + + private fun imageWithinBudget(link: String, width: Int): ImageBitmap? = + link.takeIf { it.startsWith(DATA_IMAGE) } + ?.let { decodeBase64Image(it.substringAfter("base64,"), width) } + ?.takeIf { it.width.toLong() * it.height <= pixels } + ?.also { pixels -= it.width.toLong() * it.height } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index 84bc14fee3..c488b4172b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index da64862c03..9b3162744d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -12,8 +12,20 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.* import androidx.compose.ui.platform.* @@ -22,11 +34,14 @@ import androidx.compose.ui.text.AnnotatedString.Range import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.appColors +import chat.simplex.common.views.chat.ItemSelection import chat.simplex.common.views.chat.SelectionHighlightColor import chat.simplex.common.views.helpers.* import chat.simplex.res.* @@ -53,14 +68,17 @@ private fun showFullProfileDescription(description: String) { ModalManager.end.showModalCloseable { _ -> ColumnWithScrollBar { AppBarTitle(generalGetString(MR.strings.profile_description__field)) + val parsed = parseToMarkdownText(description) MarkdownText( description, - parseToMarkdown(description), + parsed?.formattedText, + parsed?.markdownBlocks, toggleSecrets = true, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), uriHandler = LocalUriHandler.current, linkMode = chatModel.simplexLinkMode.value, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING), + blockLayout = true, ) } } @@ -85,10 +103,10 @@ private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) // Display text for a single formatted segment — must be coordinated with MarkdownText. -fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String = +fun segmentDisplayText(ft: FormattedText, mentions: Map?, linkMode: SimplexLinkMode): String = when (ft.format) { is Format.Mention -> { - val mention = ci.mentions?.get(ft.format.memberName) + val mention = mentions?.get(ft.format.memberName) if (mention?.memberRef != null) { val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" @@ -96,6 +114,7 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin } else if (mention != null) mentionText(ft.format.memberName) else ft.text } + is Format.Uri -> displayedUri(ft.text) is Format.HyperLink -> ft.format.showText ?: ft.text is Format.SimplexLink -> { val t = ft.format.showText @@ -103,13 +122,37 @@ fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLin if (t != null) "$t ${ft.format.viaHosts}" else ft.text } is Format.Command -> ft.text + is Format.Escape -> "" + is Format.Markup -> ft.format.token.drawnText else -> ft.text } -// Full display text for a chat item — joins segment display texts. -fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String { - val formattedText = ci.formattedText ?: return ci.text - return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) } +class ItemDisplay(val parts: List>, val bases: List) + +fun itemDisplay(ci: ChatItem, linkMode: SimplexLinkMode): ItemDisplay? { + val fts = ci.formattedText ?: return null + val segment = { ft: FormattedText -> + val display = segmentDisplayText(ft, ci.mentions, linkMode) + display to if (ft.format is Format.Markup) display else ft.text + } + val blocks = ci.markdownBlocks ?: return ItemDisplay(fts.map(segment), listOf(0)) + val parts = mutableListOf>() + var offset = itemPrefixText(ci).length + val bases = blockTextRanges(blocks).mapIndexed { i, t -> + if (i > 0) { + val separator = if (t.inRow) "\t" else "\n" + parts += separator to separator + offset += 1 + } + val base = if (i == 0) 0 else offset + for (j in t.range.from.coerceIn(0, fts.size) until t.range.to.coerceIn(0, fts.size)) { + val part = segment(fts[j]) + parts += part + offset += part.first.length + } + base + } + return ItemDisplay(parts, bases) } // Display-only prefix rendered before ci.text (e.g. "Spam: " for reports). @@ -119,11 +162,12 @@ fun itemPrefixText(ci: ChatItem): String = when (val mc = ci.content.msgContent) else -> "" } -// Text transformations in MarkdownText must match itemSegmentDisplayText above +// Text transformations in MarkdownText must match segmentDisplayText above @Composable fun MarkdownText ( text: CharSequence, formattedText: List? = null, + markdownBlocks: List? = null, sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, @@ -144,15 +188,44 @@ fun MarkdownText ( showTimestamp: Boolean = true, prefix: AnnotatedString? = null, stripLink: String? = null, - selectionRange: IntRange? = null, - onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null + selection: ItemSelection? = null, + textIndex: Int = 0, + blockLayout: Boolean = false, + range: IntRange? = null, + softWrap: Boolean = true, + cardColor: Color? = null, + parentDirection: LayoutDirection? = null ) { val text = if (stripLink != null) stripTextLink(text.toString(), stripLink) else text val formattedText = if (stripLink != null) stripFormattedTextLink(formattedText, stripLink) else formattedText val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } - val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) { + if (blockLayout && formattedText != null && markdownBlocks != null && range == null) { + val direction = LocalLayoutDirection.current + CompositionLocalProvider(LocalLayoutDirection provides textLayoutDirection) { + MarkdownBlocks( + formattedText, markdownBlocks, style, modifier, cardColor ?: MaterialTheme.appColors.receivedQuote, + blockText = { index, r, blockStyle, blockModifier, blockSoftWrap, last -> + MarkdownText( + text, formattedText, markdownBlocks, sender = if (index == 0) sender else null, meta = if (last) meta else null, chatTTL = chatTTL, + mentions = mentions, userMemberId = userMemberId, toggleSecrets = toggleSecrets, sendCommandMsg = sendCommandMsg, style = blockStyle, + uriHandler = uriHandler, senderBold = senderBold, modifier = blockModifier, linkMode = linkMode, onLinkLongClick = onLinkLongClick, + showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = if (index == 0) prefix else null, + selection = selection, textIndex = index, range = r.from until r.to, softWrap = blockSoftWrap, parentDirection = direction + ) + }, + metaLine = { + MarkdownText( + text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, toggleSecrets = toggleSecrets, style = style, + linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY + ) + } + ) + } + return + } + val reserve = if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current) && meta != null) { "\n" } else if (meta != null) { reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp) @@ -195,6 +268,7 @@ fun MarkdownText ( } ) } + val textSelection = selection?.text(textIndex) if (formattedText == null) { var selectableEnd = 0 val annotatedText = buildAnnotatedString { @@ -209,9 +283,9 @@ fun MarkdownText ( } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } - if (onTextLayoutResult != null) { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } + if (textSelection != null) { + SelectableText(annotatedText, style = style, modifier = modifier.then(textSelection.positionModifier), maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = textSelection.onTextLayoutResult) } else { Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } @@ -221,29 +295,38 @@ fun MarkdownText ( var hasSecrets = false var hasCommands = false var hasModals = false + val hiddenSecrets = mutableListOf() val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (prefix != null) append(prefix) - for ((i, ft) in formattedText.withIndex()) { + val headings = if (range == null && markdownBlocks != null) headingSegments(formattedText.size, markdownBlocks) else null + fun appendSecret(s: String) { + val start = length + withStyle(hiddenSecretStyle) { append(s) } + hiddenSecrets += start until length + } + for (i in range?.let { it.first.coerceAtLeast(0) .. minOf(it.last, formattedText.lastIndex) } ?: formattedText.indices) { + val ft = formattedText[i] + val heading = headings?.get(i) == true + if (heading) pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) if (ft.format == null) append(ft.text) else when(ft.format) { - is Format.Bold -> withStyle(ft.format.style) { append(ft.text) } + is Format.Bold -> withStyle(if (markdownBlocks != null && ft.format.singleStar) Format.Italic().style else ft.format.style) { append(ft.text) } is Format.Italic -> withStyle(ft.format.style) { append(ft.text) } is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) } is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) } is Format.Small -> withStyle(ft.format.style) { append(ft.text) } is Format.Colored -> withStyle(ft.format.style) { append(ft.text) } is Format.Secret -> { - val ftStyle = ft.format.style if (toggleSecrets) { hasSecrets = true val key = i.toString() withAnnotation(tag = "SECRET", annotation = key) { - if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } + if (showSecrets[key] == true) append(ft.text) else appendSecret(ft.text) } } else { - withStyle(ftStyle) { append(ft.text) } + appendSecret(ft.text) } } is Format.Mention -> { @@ -283,13 +366,13 @@ fun MarkdownText ( val s = ft.text val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s" withAnnotation(tag = "WEB_URL", annotation = link) { - withStyle(ftStyle) { append(ft.text) } + withStyle(ftStyle) { append(displayedUri(ft.text)) } } } is Format.HyperLink -> { hasLinks = true val ftStyle = Format.linkStyle - withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) { + withAnnotation(tag = if (ft.format.isEmail) "OTHER_URL" else "WEB_URL", annotation = ft.format.linkUri) { withStyle(ftStyle) { append(ft.format.showText ?: ft.text) } } } @@ -337,8 +420,15 @@ fun MarkdownText ( withStyle(ftStyle) { append(ft.text) } } } + is Format.Escape -> {} + is Format.Markup -> append( + if (range != null) ft.format.token.drawnText + else if (markdownBlocks != null) ft.format.token.previewText + else ft.text + ) is Format.Unknown -> append(ft.text) } + if (heading) pop() } selectableEnd = this.length if (meta?.isLive == true) { @@ -349,10 +439,21 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } + val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } + val layoutResult = remember { mutableStateOf(null) } + val onTextLayout: (TextLayoutResult) -> Unit = { r -> + layoutResult.value = r + textSelection?.onTextLayoutResult?.invoke(r) + } + val textModifier = modifier + .then(textSelection?.positionModifier ?: Modifier) + .then(if (hiddenSecrets.isEmpty()) Modifier else Modifier.drawWithContent { + drawContent() + drawSecretPattern(hiddenSecrets, layoutResult.value) + }) if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null) || hasModals) { val icon = remember { mutableStateOf(PointerIcon.Text) } - ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = textModifier.pointerHoverIcon(icon.value), softWrap = softWrap, maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -409,13 +510,38 @@ fun MarkdownText ( || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "MODAL", start = offset, end = offset) }, - onTextLayout = { onTextLayoutResult?.invoke(it) } + onTextLayout = onTextLayout ) } else { - if (onTextLayoutResult != null) { - SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + if (textSelection != null) { + SelectableText(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayout) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + Text(annotatedText, style = style, modifier = textModifier, softWrap = softWrap, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf(), onTextLayout = onTextLayout) + } + } + } + } +} + +private val hiddenSecretStyle = SpanStyle(color = Color.Transparent) + +private val secretTile: ImageBitmap? by lazy { MR.images.vertical_logo.toComposeImageBitmap() } + +private fun ContentDrawScope.drawSecretPattern(ranges: List, layout: TextLayoutResult?) { + val tile = secretTile ?: return + if (layout == null) return + val brush = ShaderBrush(ImageShader(tile, TileMode.Repeated, TileMode.Repeated)) + val tileScale = 8.dp.toPx() / tile.width + val saturation = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.65f) }) + val textLength = layout.layoutInput.text.length + ranges.forEach { r -> + val path = layout.getPathForRange(r.first.coerceIn(0, textLength), (r.last + 1).coerceIn(0, textLength)) + val bounds = path.getBounds() + val side = (bounds.width + bounds.height) / tileScale + clipPath(path) { + rotate(45f, bounds.center) { + scale(tileScale, bounds.center) { + drawRect(brush, Offset(bounds.center.x - side / 2, bounds.center.y - side / 2), Size(side, side), alpha = 0.35f, colorFilter = saturation) } } } @@ -499,6 +625,7 @@ private fun SelectableText( text: AnnotatedString, style: TextStyle, modifier: Modifier = Modifier, + softWrap: Boolean = true, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, selectionRange: IntRange? = null, @@ -510,6 +637,7 @@ private fun SelectableText( text = text, modifier = modifier.pointerHoverIcon(PointerIcon.Text).then(selectionHighlight(selectionRange, text.length, layoutResult)), style = style, + softWrap = softWrap, maxLines = maxLines, overflow = overflow, onTextLayout = { @@ -535,19 +663,22 @@ fun openBrowserAlert(uri: String, uriHandler: UriHandler) { if (res == null) { showInvalidLinkAlert(uri, err) } else { - val message = if (uri.count() > 160) uri.substring(0, 159) + "…" else uri + val shownUri = displayedUri(uri) + val message = if (shownUri.count() > 160) shownUri.substring(0, 159) + "…" else shownUri val sanitizedUri = res.second if (sanitizedUri == null) { AlertManager.shared.showAlertDialog( generalGetString(MR.strings.privacy_chat_list_open_web_link_question), message, confirmText = generalGetString(MR.strings.open_verb), - onConfirm = { safeOpenUri(uri, uriHandler) } + onConfirm = { safeOpenUri(uri, uriHandler) }, + parseHtml = false ) } else { AlertManager.shared.showAlertDialogButtonsColumn( generalGetString(MR.strings.privacy_chat_list_open_web_link_question), message, + parseHtml = false, buttons = { Column { SectionItemView({ @@ -585,7 +716,7 @@ fun safeOpenUri(uri: String, uriHandler: UriHandler) { } fun showInvalidLinkAlert(uri: String, error: String? = null) { - val message = if (error.isNullOrEmpty()) { uri } else { error + "\n" + uri } + val message = if (error.isNullOrEmpty()) { displayedUri(uri) } else { error + "\n" + displayedUri(uri) } AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_parsing_uri_title), message) } @@ -620,7 +751,9 @@ fun stripFormattedTextLink(ft: List?, link: String): List= 0 && result[i].format == null && result[i].text.endsWith("\n")) { + if (i >= 0 && result[i].format is Format.Markup) { + result.removeAt(i) + } else if (i >= 0 && result[i].format == null && result[i].text.endsWith("\n")) { result[i] = FormattedText(result[i].text.dropLast(1), null) if (result[i].text.isEmpty()) result.removeAt(result.lastIndex) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt index f36978cd3c..050843059c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatHelpView.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.annotatedStringResource import chat.simplex.common.views.onboarding.ReadableTextWithLink import chat.simplex.common.views.usersettings.MarkdownHelpView import chat.simplex.common.views.usersettings.simplexTeamUri -import chat.simplex.res.MR +import chat.simplex.res.* val bold = SpanStyle(fontWeight = FontWeight.Bold) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 77104b7274..3184c16e90 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -28,7 +28,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.contacts.onRequestAccepted import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.datetime.Clock @@ -586,7 +586,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection onClick = { ModalManager.center.closeModals() ModalManager.end.closeModals() - ModalManager.center.showModalCloseable(settings = true, showClose = appPlatform.isAndroid, cardScreen = true) { close -> + ModalManager.center.showModalCloseable(settings = true, cardScreen = true) { close -> ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, true, close) } showMenu.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 5f296655b7..0615f6e5dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -46,7 +46,7 @@ import chat.simplex.common.views.badges.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -776,6 +776,10 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") if (chatModel.currentUser.value == null) { chatModel.appOpenUrl.value = rhId to uri + } else if (isAppLink(uri)) { + // branched on the scheme before the connection dispatch, which an app link must never reach + // an app link must not tear down a redemption in flight + if (!isBadgeLinkIssuing()) openAppLink(rhId, uri) } else { withBGApi { chatModel.appOpenUrlConnecting.value = true @@ -784,6 +788,31 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { } } +// the app's own scheme, for every link that is not a connection link, which stays on simplex: +internal const val appLinkScheme = "simplexchat" +internal const val connectionLinkScheme = "simplex" +private const val badgeLinkPath = "/badge/code/" + +// the prefix of the raw text, not a parsed URI: a link that does not parse is still an app link +fun isAppLink(uri: String): Boolean = + uri.startsWith("$appLinkScheme:", ignoreCase = true) + +fun isConnectionLink(uri: String): Boolean = + uri.startsWith("$connectionLinkScheme:", ignoreCase = true) + +// a link type added in a later version reaches this build too, so an unknown path asks for an update +private fun openAppLink(rhId: Long?, uri: String) { + // a link that does not parse is dropped, as iOS never receives one; one that parses without + // a path, such as simplexchat:badge/code/X, is an unknown link on both platforms + val parsed = uriCreateOrNull(uri) ?: return + val path = parsed.path ?: "" + if (path.startsWith(badgeLinkPath)) { + openBadgeLink(rhId, path.removePrefix(badgeLinkPath)) + } else { + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.app_link_not_supported)) + } +} + @Composable private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState>, connectNameCandidate: MutableState) { Box { @@ -1052,7 +1081,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat SupportSimpleXBanner( title = stringResource(MR.strings.badges_support_ended), subtitle = String.format(stringResource(MR.strings.badges_support_ended_on), alert.dateText), - onTap = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }, + onTap = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, onDismiss = { showBadgeAlertDismissAlert(generalGetString(MR.strings.badges_support_ended)) } ) } @@ -1065,7 +1094,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat title = stringResource(MR.strings.badges_renewal_failed), subtitle = stringResource(MR.strings.badges_tap_for_details), warning = true, - onTap = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }, + onTap = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, onDismiss = { showBadgeAlertDismissAlert(generalGetString(MR.strings.badges_renewal_failed)) } ) } @@ -1078,7 +1107,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat showDismiss = supporterBannerTapped.value, onTap = { appPrefs.supporterBannerTapped.set(true) - ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } + ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, onDismiss = ::showSupportSimpleXDismissAlert ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 01179f07ee..89d2dfb811 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.newchat.planAndConnect import chat.simplex.common.views.chat.item.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource // Spec: spec/client/chat-list.md#ChatPreviewView @@ -277,6 +277,7 @@ fun ChatPreviewView( MarkdownText( text, formattedText, + if (formattedText == null) null else ci.markdownBlocks, sender = when { cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName else -> null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt index 349f1f12d4..ddfc4b74ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt @@ -17,7 +17,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.model.getTimestampText import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ContactConnectionView(contactConnection: PendingContactConnection) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt index 96e7fbacd0..fb575498c7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt @@ -16,7 +16,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.model.ChatInfo import chat.simplex.common.model.getTimestampText import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt index d383366dd4..c740e53a17 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/GetStakeBanner.kt @@ -28,7 +28,7 @@ import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.onboarding.GetStakeView -import chat.simplex.res.MR +import chat.simplex.res.* // Spec: spec/client/chat-list.md#GetStakeBanner @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 1c2f34e88d..31941e1f77 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -50,7 +50,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsPreferenceItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index 07058b5787..b1fe42cdfc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 382d126db7..023aea530f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.themedBackground import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.newchat.ActiveProfilePicker -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ShareListView(chatModel: ChatModel, stopped: Boolean) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index fe61859937..17e288ff00 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -37,7 +37,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.ReactionIcon import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 81a6b31323..ea567d7da6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -34,7 +34,7 @@ import chat.simplex.common.views.localauth.VerticalDivider import chat.simplex.common.views.remote.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.AppearanceScope.ColorModeSwitcher -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -217,23 +217,43 @@ fun UserPicker( @Composable fun SecondSection() { - UserPickerOptionRow( - painterResource(MR.images.ic_qr_code), - if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address), - showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped - ) - UserPickerOptionRow( - painterResource(MR.images.ic_toggle_on), - stringResource(MR.strings.chat_preferences), - click = if (stopped) null else ({ - showCustomModal { m, close -> - PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close) - }() - }), - disabled = stopped - ) + if (!chatModel.desktopNoUserNoRemote) { + UserPickerOptionRow( + painterResource(MR.images.ic_qr_code), + if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address), + showCustomModal { it, close -> + val user = it.currentUser.value + if (user != null) { + UserAddressView(it, shareViaProfile = user.addressShared, close = close) + } else { + val modal = this + LaunchedEffect(modal, ModalManager.start.modalCount.value) { + if (ModalManager.start.isLastModal(modal)) close() + } + } + }, disabled = stopped + ) + UserPickerOptionRow( + painterResource(MR.images.ic_toggle_on), + stringResource(MR.strings.chat_preferences), + click = if (stopped) null else ({ + showCustomModal { m, close -> + val user = m.currentUser.value + if (user != null) { + PreferencesView(m, user, close) + } else { + val modal = this + LaunchedEffect(modal, ModalManager.start.modalCount.value) { + if (ModalManager.start.isLastModal(modal)) close() + } + } + }() + }), + disabled = stopped + ) + } if (appPlatform.isDesktop) { - Divider(Modifier.padding(DEFAULT_PADDING)) + if (!chatModel.desktopNoUserNoRemote) Divider(Modifier.padding(DEFAULT_PADDING)) val inactiveUsers = users.filter { !it.user.activeUser } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index 371e9072ea..8c77d7560f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.ContactType import chat.simplex.common.views.newchat.chatContactType -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay fun onRequestAccepted(chat: Chat) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index 524fbedb1c..a430b521c2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.newchat.ContactType import chat.simplex.common.views.newchat.chatContactType -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun ContactPreviewView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index b656b8b8da..8f22515370 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -25,6 +25,9 @@ import androidx.compose.ui.text.input.* import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs @@ -32,7 +35,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.datetime.Clock @@ -44,6 +47,7 @@ fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) { val useKeychain = remember { mutableStateOf(appPrefs.storeDBPassphrase.get()) } val initialRandomDBPassphrase = remember { mutableStateOf(appPrefs.initialRandomDBPassphrase.get()) } val storedKey = remember { val key = DatabaseUtils.ksDatabasePassword.get(); mutableStateOf(key != null && key != "") } + val keyStorage = remember(storedKey.value) { if (storedKey.value) DatabaseUtils.ksDatabasePassword.storage() else null } // Do not do rememberSaveable on current key to prevent saving it on disk in clear text val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") } val newKey = rememberSaveable { mutableStateOf("") } @@ -60,6 +64,7 @@ fun DatabaseEncryptionView(m: ChatModel, migration: Boolean) { newKey, confirmNewKey, storedKey, + keyStorage, initialRandomDBPassphrase, progressIndicator, migration, @@ -105,6 +110,7 @@ fun DatabaseEncryptionLayout( newKey: MutableState, confirmNewKey: MutableState, storedKey: MutableState, + keyStorage: String?, initialRandomDBPassphrase: MutableState, progressIndicator: MutableState, migration: Boolean, @@ -138,7 +144,10 @@ fun DatabaseEncryptionLayout( } } - if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) { + val hasCurrentField = !initialRandomDBPassphrase.value && chatDbEncrypted == true + val newKeyContentType = + if (hasCurrentField) ContentType.NewPassword else newPasswordContentType() + if (hasCurrentField) { PassphraseField( currentKey, generalGetString(MR.strings.current_passphrase), @@ -155,6 +164,7 @@ fun DatabaseEncryptionLayout( showStrength = true, isValid = ::validKey, keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + contentType = newKeyContentType, ) val onClickUpdate = { // Don't do things concurrently. Shouldn't be here concurrently, just in case @@ -184,6 +194,7 @@ fun DatabaseEncryptionLayout( generalGetString(MR.strings.confirm_new_passphrase), modifier = Modifier.padding(horizontal = DEFAULT_PADDING), isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, + contentType = newKeyContentType, keyboardActions = KeyboardActions(onDone = { if (!disabled) onClickUpdate() defaultKeyboardAction(ImeAction.Done) @@ -196,7 +207,7 @@ fun DatabaseEncryptionLayout( } Column { - DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase, migration) + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, keyStorage, initialRandomDBPassphrase, migration) } SectionBottomSpacer() } @@ -254,6 +265,7 @@ expect fun DatabaseEncryptionFooter( useKeychain: MutableState, chatDbEncrypted: Boolean?, storedKey: MutableState, + keyStorage: String?, initialRandomDBPassphrase: MutableState, migration: Boolean, ) @@ -319,6 +331,7 @@ fun PassphraseField( keyboardActions: KeyboardActions = KeyboardActions(), dependsOn: State? = null, requestFocus: Boolean = false, + contentType: ContentType? = ContentType.Password, ) { var valid by remember { mutableStateOf(validKey(key.value)) } var showKey by remember { mutableStateOf(false) } @@ -358,6 +371,7 @@ fun PassphraseField( minWidth = TextFieldDefaults.MinWidth, minHeight = TextFieldDefaults.MinHeight ) + .semantics { if (contentType != null) this.contentType = contentType } .focusRequester(focusRequester), onValueChange = { state.value = it @@ -368,7 +382,7 @@ fun PassphraseField( visualTransformation = if (showKey) VisualTransformation.None else - VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) }, + PasswordVisualTransformation('*'), keyboardOptions = keyboardOptions, keyboardActions = KeyboardActions(onDone = { keyboard?.hide() @@ -434,6 +448,7 @@ suspend fun encryptDatabase( m.controller.apiSaveAppSettings(AppSettings.current.prepareForExport()) } val error = m.controller.apiStorageEncryption(currentKey.value, newKey.value) + if (error == null && currentKey.value != newKey.value) appPrefs.shouldDeleteDatabaseBackups.set(true) appPrefs.encryptionStartedAt.set(null) val sqliteError = ((error as? ChatError.ChatErrorDatabase)?.databaseError as? DatabaseError.ErrorExport)?.sqliteError when { @@ -459,12 +474,13 @@ suspend fun encryptDatabase( if (migration) { appPreferences.storeDBPassphrase.set(useKeychain.value) } - resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) if (useKeychain.value) { DatabaseUtils.ksDatabasePassword.set(new) } else { removePassphraseFromKeyChain(useKeychain, storedKey, migration) } + // DatabaseEncryptionView reads key storage when storedKey changes, so storedKey is updated after the key is saved + resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value) operationEnded(m, progressIndicator) { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.database_encrypted)) } @@ -539,6 +555,7 @@ fun PreviewDatabaseEncryptionLayout() { newKey = remember { mutableStateOf("") }, confirmNewKey = remember { mutableStateOf("") }, storedKey = remember { mutableStateOf(true) }, + keyStorage = stringResource(MR.strings.keystore_key_storage_strongbox), initialRandomDBPassphrase = remember { mutableStateOf(true) }, progressIndicator = remember { mutableStateOf(false) }, migration = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 4aeb929624..4b59dd81c9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -22,7 +22,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.AppVersionText import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index c6bbb93294..6abbf83226 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.datetime.* import java.io.* import java.net.URI @@ -598,6 +598,11 @@ fun deleteChatDatabaseFilesAndState() { ntfManager.cancelAllNotifications() } +fun deleteDatabaseBackups() { + File(dataDir, "$chatDatabaseFileName.bak").delete() + File(dataDir, "$agentDatabaseFileName.bak").delete() +} + private suspend fun exportArchive( m: ChatModel, progressIndicator: MutableState, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 62ba2c10f3..9c64e7ce85 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -24,7 +24,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.model.LocalBadge import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt index cf2ceaf2d6..2c6eed78f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.graphics.* import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import kotlin.math.absoluteValue diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 91919ff0cf..036bcab68d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -38,7 +38,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.badges.openBadgesView import chat.simplex.common.views.newchat.noShownBadge -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import kotlin.math.max import kotlin.math.roundToInt diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt index c1a76d7bf8..50341761c5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt @@ -14,7 +14,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.serialization.SerialName diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt index 3c44cbb4dd..ed10c0f86f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.CustomTimeUnit import chat.simplex.common.model.timeText -import chat.simplex.res.MR +import chat.simplex.res.* @Composable expect fun CustomTimePicker( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index e584fcc11c..41556236b3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -2,7 +2,7 @@ package chat.simplex.common.views.helpers import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.serialization.* import java.io.File import java.security.SecureRandom @@ -38,6 +38,8 @@ object DatabaseUtils { passphrase.set(null) initVector.set(null) } + + fun storage(): String? = cryptor.keyStorage(alias) } fun hasAtLeastOneDatabase(rootDir: String): Boolean = diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt index b0366cceb3..e5feb49107 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultBasicTextField.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.text.input.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.views.database.PassphraseStrength -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 81fac40a40..1c2ef93a09 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -17,7 +17,7 @@ import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.CenteredRowLayout -import chat.simplex.res.MR +import chat.simplex.res.* import kotlin.math.absoluteValue @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt index 893ff5a467..2867f6d8a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import dev.icerock.moko.resources.ImageResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt index 8aea99eb4e..258fede5b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt @@ -23,7 +23,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.chatViewScrollState import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID import chat.simplex.common.views.chat.item.imageViewFullWidth -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt index 1f2b5485f2..5e040042dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LocalAuthentication.kt @@ -8,7 +8,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.BackHandler import chat.simplex.common.views.localauth.LocalAuthView import chat.simplex.common.views.usersettings.LAMode -import chat.simplex.res.MR +import chat.simplex.res.* sealed class LAResult { object Success: LAResult() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 02c0b45de4..3655c4053d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatController.appPrefs -import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import LocalCardScreen import chat.simplex.common.ui.theme.* @@ -91,7 +90,10 @@ class ModalData(val keyboardCoversBar: Boolean = true) { enum class ModalViewId { SECONDARY_CHAT, - CONTEXT_USER_PICKER_INCOGNITO + CONTEXT_USER_PICKER_INCOGNITO, + BADGE_LINK, + BADGE_REDEEM_CODE, + BADGES } class ModalManager(private val placement: ModalPlacement? = null) { @@ -116,6 +118,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + fun isLastModal(data: ModalData): Boolean = modalViews.getOrNull(modalViews.size - toRemove.size - 1)?.data === data + fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { showCustomModal(id = id, forceAnimated = forceAnimated) { close -> ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content() }) @@ -142,9 +146,7 @@ class ModalManager(private val placement: ModalPlacement? = null) { modalViews.add(ModalViewHolder(id, anim, data, modal)) _modalCount.value = modalViews.size - toRemove.size - if (placement == ModalPlacement.CENTER) { - ChatModel.chatId.value = null - } else if (placement == ModalPlacement.END) { + if (placement == ModalPlacement.END) { desktopExpandWindowToWidth(DEFAULT_START_MODAL_WIDTH * sqrt(appPrefs.fontScale.get()) + DEFAULT_MIN_CENTER_MODAL_WIDTH + DEFAULT_END_MODAL_WIDTH * sqrt(appPrefs.fontScale.get())) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt index 3d21cfaf0f..27064b21d5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt @@ -5,7 +5,7 @@ import chat.simplex.common.platform.Log import chat.simplex.common.platform.TAG import chat.simplex.common.platform.ntfManager import chat.simplex.common.views.database.restartChatOrApp -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Job import kotlinx.coroutines.delay diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt index cc6b1c40a4..790174eaf6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.text.input.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 5196a144b1..32370ce290 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SelectableCard import chat.simplex.common.views.usersettings.SettingsActionItemWithContent -import chat.simplex.res.MR +import chat.simplex.res.* val SectionCardShape = RoundedCornerShape(16.dp) val CARD_PADDING = 18.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt index 1c4879dedd..d59fc1fe13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import chat.simplex.common.platform.appPlatform import chat.simplex.common.ui.theme.SimpleXTheme -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SimpleButton(text: String, icon: Painter, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt index e0e61b598e..7f8b902b94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SubscriptionStatusIcon.kt @@ -5,7 +5,7 @@ import androidx.compose.material.Icon import androidx.compose.ui.graphics.Color import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index 8ae27ca17f..7d0851e65b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -132,13 +132,16 @@ fun PlainTextEditor( @Serializable data class ParsedFormattedText( - val formattedText: List? = null + val formattedText: List? = null, + val markdownBlocks: List? = null ) -fun parseToMarkdown(text: String): List? { +fun parseToMarkdown(text: String): List? = parseToMarkdownText(text)?.formattedText + +fun parseToMarkdownText(text: String): ParsedFormattedText? { val formatted = chatParseMarkdown(text) return try { - json.decodeFromString(ParsedFormattedText.serializer(), formatted).formattedText + json.decodeFromString(ParsedFormattedText.serializer(), formatted) } catch (e: Exception) { Log.e(TAG, "Failed to parse into markdown: " + e.stackTraceToString()) null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt index c8c24d918a..98ea245886 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt @@ -19,7 +19,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.AppearanceScope.WallpaperPresetSelector import chat.simplex.common.views.usersettings.AppearanceScope.editColor -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.serialization.encodeToString diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 2d6e70eae9..81a64cb527 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -13,7 +13,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.ThemeModeOverrides import chat.simplex.common.ui.theme.ThemeOverrides import chat.simplex.common.views.chatlist.connectIfOpenedViaUri -import chat.simplex.res.MR +import chat.simplex.res.* import com.charleskorn.kaml.decodeFromStream import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -617,13 +617,27 @@ fun UriHandler.openExternalLink(uri: String) { } else { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.open_external_link_title), - text = uri, + text = displayedUri(uri), confirmText = generalGetString(MR.strings.open_verb), - onConfirm = { uriHandler.openUriCatching(uri) } + onConfirm = { uriHandler.openUriCatching(uri) }, + parseHtml = false ) } } +val invisibleChar = Regex("\\p{Cf}") + +private val nonAsciiChar = Regex("[^\\x00-\\x7F]") + +fun unicodeMarker(c: String): String = "[U+" + Integer.toHexString(c.codePointAt(0)).uppercase().padStart(4, '0') + "]" + +fun displayedUri(uri: String): String { + val authorityStart = uri.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: 0 + val authorityEnd = uri.indexOfAny(charArrayOf('/', '?', '#'), authorityStart).takeIf { it >= 0 } ?: uri.length + val authority = uri.substring(authorityStart, authorityEnd).replace(nonAsciiChar) { unicodeMarker(it.value) } + return uri.replaceRange(authorityStart, authorityEnd, authority).replace(invisibleChar) { unicodeMarker(it.value) } +} + fun IntSize.Companion.Saver(): Saver = Saver( save = { it.width to it.height }, restore = { IntSize(it.first, it.second) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index f86edb0388..04c1239d84 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -11,7 +11,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils.ksAppPassword import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.platform.* import chat.simplex.common.views.database.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt index 669847c3a9..957eb099d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt @@ -15,7 +15,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.chat.group.ProgressIndicator import chat.simplex.common.views.helpers.SimpleButton import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun PasscodeView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt index d16f0a2a38..9305e21cc4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun PasscodeEntry( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt index 1d620c915f..a42949493b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/SetAppPasscodeView.kt @@ -7,7 +7,7 @@ import chat.simplex.common.views.helpers.DatabaseUtils import chat.simplex.common.views.helpers.DatabaseUtils.ksAppPassword import chat.simplex.common.views.helpers.DatabaseUtils.ksSelfDestructPassword import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SetAppPasscodeView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 87784f009e..05c22155b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.LinkTextView import chat.simplex.common.views.newchat.SimpleXLinkQRCode import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index f92a5e0ce4..150be7ea6b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -29,7 +29,7 @@ import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.OnionRelatedLayout -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* @@ -453,7 +453,7 @@ private fun MutableState.PassphraseEnteringView(currentKey: S } } ) {} - DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, remember { mutableStateOf(false) }, true) + DatabaseEncryptionFooter(useKeychain, chatDbEncrypted = true, remember { mutableStateOf(false) }, null, remember { mutableStateOf(false) }, true) } if (verifyingPassphrase.value) { ProgressView() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt index 639a5cc78e..7ad3076640 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -32,7 +32,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView import chat.simplex.common.views.chat.group.hostFromRelayLink -import chat.simplex.res.MR +import chat.simplex.res.* import java.net.URI import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt index 077abd1b98..9198ec819a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactLearnMore.kt @@ -11,7 +11,7 @@ import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.common.views.helpers.KeyChangeEffect import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.onboarding.ReadableTextWithLink -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun AddContactLearnMore(close: () -> Unit) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 1d8da1690c..e2c89940ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -29,7 +29,7 @@ import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.usersettings.* import androidx.compose.ui.layout.ContentScale import chat.simplex.common.BuildConfigCommon -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 5e5769891a..8b145ef206 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -14,7 +14,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* enum class ConnectionLinkType { @@ -717,8 +717,7 @@ fun showPrepareContactAlert( image = contactShortLinkData.profile.image, icon = if (contactShortLinkData.business) MR.images.ic_work_filled_padded - else if (contactShortLinkData.profile.peerType == ChatPeerType.Bot) MR.images.ic_cube - else MR.images.ic_account_circle_filled + else peerTypeIcon(contactShortLinkData.profile.peerType) ) }, profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index f1bd732d87..f6109778dc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 4d97e3a054..bdad9d19c4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -34,7 +34,7 @@ import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.contacts.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index f3006d221b..29819f21cb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -42,7 +42,7 @@ import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.BuildConfigCommon -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* enum class NewChatOption { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt index 661e752751..cf708c72d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -38,7 +38,7 @@ import chat.simplex.common.views.chatlist.openGetStake import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.crowdfundingAvailable import chat.simplex.common.views.usersettings.UserAddressView -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.launch import kotlin.math.cos import kotlin.math.sin diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 74c9a55ecf..7171c7475c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -16,7 +16,7 @@ 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 chat.simplex.res.* import kotlinx.coroutines.launch @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index d11e396388..30be129aa8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -34,7 +34,7 @@ import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.usersettings.networkAndServers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 9d82395740..a205a11145 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -19,7 +19,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt index 97dfcd34b9..5aca7a20c9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt @@ -17,7 +17,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.remote.AddingMobileDevice import chat.simplex.common.views.remote.DeviceNameField import chat.simplex.common.views.usersettings.PreferenceToggle -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt index 684bfb0053..c5361452ad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingLayout.kt @@ -26,7 +26,7 @@ import chat.simplex.common.views.helpers.mixWith import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt index adcfb8b194..4dfd6445ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetNotificationsMode.kt @@ -18,7 +18,7 @@ import chat.simplex.common.model.NotificationsMode import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 9ef72a7f12..f198fe1f7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.focus.* import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalFocusManager @@ -19,7 +20,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.database.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay @Composable @@ -152,6 +153,7 @@ private fun SetupDatabasePassphraseLayout( showStrength = true, isValid = ::validKey, keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), + contentType = newPasswordContentType(), ) PassphraseField( @@ -169,6 +171,7 @@ private fun SetupDatabasePassphraseLayout( }, isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done) }), + contentType = newPasswordContentType(), ) } Spacer(Modifier.weight(1f)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index d97d709953..41229037b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -35,7 +35,7 @@ import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.migration.MigrateToDeviceView import chat.simplex.common.views.migration.MigrationToState -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlin.math.ceil import kotlin.math.floor diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 39a6514302..81ea810acb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -59,7 +59,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.UserAddressView import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView import chat.simplex.common.views.usersettings.showAddShortLinkAlert -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlin.math.absoluteValue diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt index b20cfe3096..45f7927c8b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.newchat.darkStops import chat.simplex.common.views.newchat.gradientPoints import chat.simplex.common.views.newchat.lightStops import chat.simplex.common.views.usersettings.changeNotificationsMode -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 2d2de2881f..01b054fb28 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -35,7 +35,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 8caf038481..57700aeba3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -36,7 +36,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.networkAndServers.validPort -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index 93c5eb7756..a661568a0d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -40,7 +40,7 @@ import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.ui.theme.ThemeManager.toReadableHex import chat.simplex.common.views.chat.item.PreviewChatItemView import chat.simplex.common.views.chat.item.msgTailWidthDp -import chat.simplex.res.MR +import chat.simplex.res.* import com.godaddy.android.colorpicker.ClassicColorPicker import com.godaddy.android.colorpicker.HsvColor import dev.icerock.moko.resources.StringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt index 43863d5794..ca9c66df27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.appPlatform -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun CallSettingsView(m: ChatModel, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index 2c729149d0..fce6c69033 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -15,7 +15,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.TerminalView import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> Unit @@ -60,6 +60,7 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> } SettingsPreferenceItem(painterResource(MR.images.ic_report), stringResource(MR.strings.show_internal_errors), appPreferences.showInternalErrors) SettingsPreferenceItem(painterResource(MR.images.ic_avg_pace), stringResource(MR.strings.show_slow_api_calls), appPreferences.showSlowApiCalls) + SettingsActionItem(painterResource(MR.images.ic_folder_open), stringResource(MR.strings.storage), { ModalManager.start.showModal(cardScreen = true) { StorageView() } }) } } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt index aaaef31583..74afa93f06 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HelpView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.chatlist.ChatHelpView import chat.simplex.common.views.helpers.AppBarTitle -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun HelpView(userDisplayName: String) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index 4a3806ab89..8e96bec184 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.UserProfileRow import chat.simplex.common.views.database.PassphraseField import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun HiddenProfileView( @@ -70,10 +70,10 @@ private fun HiddenProfileLayout( val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || !passwordValid || confirmHidePassword.value == "" || !confirmValid } } SectionView(stringResource(MR.strings.hidden_profile_password)) { SectionItemViewWithoutMinPadding { - PassphraseField(hidePassword, generalGetString(MR.strings.password_to_show), isValid = { passwordValid }, showStrength = true) + PassphraseField(hidePassword, generalGetString(MR.strings.password_to_show), isValid = { passwordValid }, showStrength = true, contentType = null) } SectionItemViewWithoutMinPadding { - PassphraseField(confirmHidePassword, stringResource(MR.strings.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword) + PassphraseField(confirmHidePassword, stringResource(MR.strings.confirm_password), isValid = { confirmValid }, dependsOn = hidePassword, contentType = null) } SectionItemViewSpaceBetween({ saveProfilePassword(hidePassword.value) }, disabled = saveDisabled, minHeight = TextFieldDefaults.MinHeight) { Text(generalGetString(MR.strings.save_profile_password), color = if (saveDisabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt index 6098f66941..7f1081ff3d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt @@ -12,7 +12,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.onboarding.ReadableTextWithLink -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun IncognitoView() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt index ab2df2014c..2810cb02ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/MarkdownHelpView.kt @@ -14,7 +14,7 @@ import chat.simplex.common.model.Format import chat.simplex.common.model.FormatColor import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.ui.theme.SimpleXTheme -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun MarkdownHelpView() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 91324bb39a..083e5a07ae 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.text.style.TextOverflow import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlin.collections.ArrayList @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index 63f3491d80..6a06432c6d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -18,7 +18,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 314537f579..1696e5f1fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.theme.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt index 0c31b062dd..57c77df844 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt @@ -23,7 +23,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.parseRTCIceServers import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun RTCServersView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt index ef4acdeac6..003300a033 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt index f2c7b02b42..fc120fb7ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetSimplexNameView.kt @@ -21,7 +21,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* import chat.simplex.common.views.chat.item.openBrowserAlert import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 32f1b6384a..3e36ebda0f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -35,7 +35,7 @@ import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView import chat.simplex.common.views.onboarding.crowdfundingAvailable import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) { @@ -110,7 +110,7 @@ fun SettingsLayout( SectionView { // Direct showModal (no settings / cardScreen flags) — settings-style card chrome would render // a gray top bar / back button that badges views don't want (they have their own inline titles). - SectionItemView(click = { ModalManager.start.showCustomModal { close -> BadgesView(ModalManager.start, close) } }) { + SectionItemView(click = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }) { val badgeType = chatModel.currentUser.value?.profile?.localBadge?.badge?.badgeType ?: BadgeType.Supporter Image(painterResource(badgeImage(badgeType)), stringResource(MR.strings.supporter_perks), Modifier.size(24.dp)) TextIconSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt new file mode 100644 index 0000000000..a3e0c3206b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/StorageView.kt @@ -0,0 +1,100 @@ +package chat.simplex.common.views.usersettings + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemViewSpaceBetween +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.nio.file.* +import java.nio.file.attribute.BasicFileAttributes + +private class EntryUsage(val name: String, val bytes: Long) + +private class RootUsage(val dir: File, val entries: List) { + val bytes: Long = entries.sumOf { it.bytes } +} + +@Composable +fun StorageView() { + val usage by produceState?>(initialValue = null) { + value = withContext(Dispatchers.IO) { storageRoots().map(::rootUsage) } + } + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.storage)) + val roots = usage + if (roots == null) { + DefaultProgressView(null) + } else { + roots.forEachIndexed { i, root -> + if (i > 0) SectionDividerSpaced() + SectionView(root.dir.path) { + UsageRow(stringResource(MR.strings.storage_total), root.bytes) + root.entries.forEach { UsageRow(it.name, it.bytes) } + } + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun UsageRow(name: String, bytes: Long) { + SectionItemViewSpaceBetween { + Text(name, Modifier.weight(1f)) + Text(formatBytes(bytes), Modifier.padding(start = DEFAULT_PADDING), color = MaterialTheme.colors.secondary) + } +} + +private fun storageRoots(): List { + val dirs = listOf(dataDir, preferencesDir, tmpDir).map { it.normalize() }.distinct() + return dirs.filter { dir -> dirs.none { it != dir && dir.startsWith(it) } } +} + +private fun rootUsage(root: File): RootUsage = + RootUsage( + root, + rootEntries(root) + .map { EntryUsage(it.fileName.toString(), treeSize(it)) } + .sortedByDescending { it.bytes } + ) + +private fun rootEntries(root: File): List = + try { + Files.newDirectoryStream(root.toPath()).use { it.toList() } + } catch (e: Exception) { + Log.e(TAG, "StorageView rootEntries: ${e.javaClass.simpleName}") + emptyList() + } + +private fun treeSize(path: Path): Long { + var bytes = 0L + Files.walkFileTree(path, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + bytes += attrs.size() + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + Log.e(TAG, "StorageView visitFileFailed: ${exc.javaClass.simpleName}") + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) Log.e(TAG, "StorageView postVisitDirectory: ${exc.javaClass.simpleName}") + return FileVisitResult.CONTINUE + } + }) + return bytes +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt index ea0cd4fe28..8167bae62a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressLearnMore.kt @@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 87660c7e65..826648ddb8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -33,7 +33,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.newchat.* import chat.simplex.common.BuildConfigCommon -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -63,6 +63,7 @@ fun UserAddressView( chatModel.updateUser(u) } } catch (e: Exception) { + shareViaProfile.value = !on Log.e(TAG, "UserAddressView apiSetProfileAddress: ${e.stackTraceToString()}") } finally { progressIndicator.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index f2a4cc7dac..cc1149e44d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.platform.* import chat.simplex.common.views.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.net.URI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index ac21fb6b23..e91d6c2ce0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -30,7 +30,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.database.* import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -277,7 +277,7 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: ( @Composable fun PasswordAndAction(label: StringResource, color: Color = MaterialTheme.colors.primary) { SectionView() { SectionItemViewWithoutMinPadding { - PassphraseField(actionPassword, generalGetString(MR.strings.profile_password), isValid = { passwordValid }, showStrength = true) + PassphraseField(actionPassword, generalGetString(MR.strings.profile_password), isValid = { passwordValid }, showStrength = true, contentType = null) } SectionItemViewSpaceBetween({ doAction(actionPassword.value) }, disabled = !actionEnabled, minHeight = TextFieldDefaults.MinHeight) { Text(generalGetString(label), color = if (actionEnabled) color else MaterialTheme.colors.secondary) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt index 5070c3c0aa..e297ef62a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt @@ -18,7 +18,7 @@ import chat.simplex.common.platform.appPlatform import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.AppBarTitle -import chat.simplex.res.MR +import chat.simplex.res.* @Composable fun VersionInfoView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 42746006a3..3a6fc0fb5c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -30,7 +30,7 @@ import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.platform.chatModel import chat.simplex.common.views.usersettings.PreferenceToggle import chat.simplex.common.views.usersettings.SettingsPreferenceItem -import chat.simplex.res.MR +import chat.simplex.res.* import java.text.DecimalFormat @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt index 9a2d7f8e61..f68dd0c0dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt @@ -23,7 +23,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggle -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 8241422d0a..92714b5910 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -43,7 +43,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt index a3a843d034..6e8d919afc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -8,7 +8,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index 53277c9ccd..20a182551c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -31,12 +31,16 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.mikepenz.markdown.compose.Markdown +import com.mikepenz.markdown.compose.components.MarkdownComponents import com.mikepenz.markdown.compose.components.markdownComponents import com.mikepenz.markdown.compose.elements.MarkdownHeader import com.mikepenz.markdown.m2.markdownColor import com.mikepenz.markdown.m2.markdownTypography +import com.mikepenz.markdown.model.MarkdownColors +import com.mikepenz.markdown.model.MarkdownPadding +import com.mikepenz.markdown.model.MarkdownTypography import com.mikepenz.markdown.model.markdownPadding import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -771,22 +775,36 @@ expect fun ConditionsBox(modifier: Modifier, scrollState: ScrollState, content: @Composable private fun ConditionsMarkdown(text: String) { + val style = documentMarkdownStyle() Markdown(text, - markdownColor(linkText = MaterialTheme.colors.primary), + style.colors, + style.typography, + Modifier.padding(8.dp), + // using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly + flavour = CommonMarkFlavourDescriptor(), + components = style.components, + padding = style.padding + ) +} + +class DocumentMarkdownStyle(val colors: MarkdownColors, val typography: MarkdownTypography, val components: MarkdownComponents, val padding: MarkdownPadding) + +@Composable +fun documentMarkdownStyle(h1: TextStyle = MaterialTheme.typography.body1): DocumentMarkdownStyle = + DocumentMarkdownStyle( + markdownColor(), markdownTypography( - h1 = MaterialTheme.typography.body1, + h1 = h1, h2 = MaterialTheme.typography.h3.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold), h3 = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), h4 = MaterialTheme.typography.h5.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold), h5 = MaterialTheme.typography.h6.copy(fontWeight = FontWeight.Bold), link = MaterialTheme.typography.body1.copy( + color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline ) ), - Modifier.padding(8.dp), - // using CommonMarkFlavourDescriptor instead of GFMFlavourDescriptor because it shows `https://simplex.chat/` (link inside backticks) incorrectly - flavour = CommonMarkFlavourDescriptor(), - components = markdownComponents( + markdownComponents( heading2 = { Spacer(Modifier.height(10.dp)) MarkdownHeader(it.content, it.node, it.typography.h2) @@ -803,9 +821,8 @@ private fun ConditionsMarkdown(text: String) { Spacer(Modifier.height(4.dp)) }, ), - padding = markdownPadding(block = 4.dp) + markdownPadding(block = 4.dp) ) -} @Composable private fun ConditionsLinkView(conditionsLink: String) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index 3c8cc9e0ce..c1c423e981 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -27,7 +27,7 @@ import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.PreferenceToggle -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 63365bd080..6e1fad133a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -24,7 +24,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.usersettings.SettingsActionItem -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt index d280773976..68242a22b1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ScanProtocolServer.kt @@ -7,7 +7,7 @@ import chat.simplex.common.model.UserServer import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner -import chat.simplex.res.MR +import chat.simplex.res.* @Composable expect fun ScanProtocolServer(rhId: Long?, onNext: (UserServer) -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml index cd4e0a89d1..0578d769c5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ar/strings.xml @@ -43,7 +43,7 @@ قُبلت المكالمة اسمح بالمكالمات فقط إذا سمحت جهة اتصالك بذلك. اسمح بردود الفعل على الرسائل فقط إذا سمحت جهة اتصالك بذلك. - يتم استخدام Android Keystore لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل. + يتم استخدام Android Keystore%s لتخزين عبارة المرور بشكل آمن - فهو يسمح لخدمة الإشعارات بالعمل. يتم إنشاء ملف تعريف دردشة فارغ بالاسم المقدم، ويفتح التطبيق كالمعتاد. أجب الاتصال دائمًا diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/az/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/az/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/az/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/az/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml similarity index 97% rename from apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index 20d5185cb5..cd80492607 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -25,6 +25,7 @@ You are an owner Invalid link Please check that SimpleX link is correct. + This link is not supported by this app version. Please check for app updates. Opening database… @@ -140,6 +141,7 @@ No message servers. No servers to receive messages. No servers for private message routing. + Server %1$s has more than two host names. No media & file servers. No servers to send files. No servers to receive files. @@ -254,6 +256,7 @@ Error opening chat Error opening group Error changing profile + Error saving profile Instant notifications @@ -585,6 +588,17 @@ Attach + Emoji + Smileys & emotion + People & body + Animals & nature + Food & drink + Travel & places + Activities + Objects + Symbols + Flags + No emoji found Context icon Cancel image preview Cancel file preview @@ -685,6 +699,9 @@ File saved File not found Error saving file + Formatted view + Text view + This file has text direction characters. The text can be shown in a different order than it is written. Loading the file Please, wait while the file is being loaded from the linked mobile File error @@ -1158,6 +1175,8 @@ Deprecated options Show internal errors Show slow API calls + Storage + Total Shutdown? Notifications will stop working until you re-launch the app Error saving settings @@ -1282,6 +1301,7 @@ received answer… received confirmation… connecting… + reconnecting… connected ended @@ -1415,6 +1435,8 @@ no e2e encryption contact has e2e encryption contact has no e2e encryption + Call is not encrypted + %s accepted the call without end-to-end encryption. peer-to-peer via relay Hang up @@ -1642,6 +1664,9 @@ Save passphrase in Keystore Save passphrase in settings + Secure element + TEE + Software Database encrypted! Error encrypting database Remove passphrase from Keystore? @@ -1658,7 +1683,7 @@ Set passphrase Please enter correct current passphrase. Your chat database is not encrypted - set passphrase to protect it. - Android Keystore is used to securely store passphrase - it allows notification service to work. + Android Keystore%s is used to securely store passphrase - it allows notification service to work. The passphrase is stored in settings as plaintext. Database is encrypted using a random passphrase, you can change it. Please note: you will NOT be able to recover or change passphrase if you lose it.]]> @@ -1760,6 +1785,8 @@ You joined this group You rejected group invitation Group invitation expired + For you only + For %1$s only deleted contact @@ -3143,8 +3170,10 @@ The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. Supporter Legend - Send 2GB files - Send 5GB files + Files up to 2 GB + Stored for 7 days + Files up to 5 GB + Stored for 21 days %1$s/month %1$s/year Pay %1$s/month @@ -3152,19 +3181,20 @@ Pay %1$s Loading… Not available - Save %1$s - Optional profile badge\nand 2GB files - Optional profile badge\nand 5GB files + %1$s off + Supporter: 2 GB files available for 7 days. + Legend: 5 GB files available for 21 days. 1 month Monthly Annual Renews on %1$s. Cancel anytime. Ends on %1$s. - Your level + Choose your badge + Larger files that stay available longer. + How long? Support SimpleX Get a badge to send larger files (2-5GB) that stay available longer (7-21 days), and to show it on your profile. Why SimpleX is built. - Choose your level Redeem badge code Continue How badges protect your privacy @@ -3178,19 +3208,36 @@ Get badge + better files You can support SimpleX later in Settings. Support SimpleX to send larger files that stay available longer - Purchase successful - Purchase pending - The purchase is awaiting approval. This build does not deliver purchases approved later. Purchase error - Get your code + Check your order + Duration + Total + Issuing your badge + Your payment is complete. The badge will be added to this profile. + Waiting for approval + Nothing has been charged. + Checking your purchases + Buy in browser Redeem code + Add badge to your profile? + The badge will be added to the profile %1$s. + Add badge + Profile already has a badge + A badge can be added to another profile, or here once this badge ends. + Its code is on the page you bought it on, under Show code. + View your badge + The badge will be added to this profile. + Badge added + The badge was added to the profile %1$s. Paste the code you received. SB-XXXXX-XXXXX-XXXXX-XXXXX Redeem Cannot redeem code This code is not valid. This app version cannot redeem badge codes. + This app version cannot buy badges. This profile already has a badge. Redeem the code on another profile, or once this badge ends. + This profile already has a badge. This code was not recognized. This code has already been used. This code has expired. @@ -3200,6 +3247,13 @@ This app version is too old for the badge service. Please update the app. The badge service does not recognize this badge. The badge service reported an internal error. + The store did not confirm this purchase. + This purchase was already credited to a badge on another device or profile. + Test purchases are not credited. Badges can only be bought with a real purchase. + The payment has not been confirmed yet. + The store could not be reached. + Badges cannot be bought in the app yet. + This badge is not on sale. The code was accepted, but the badge it grants has already ended. The code could not be redeemed. The code you scanned is not a badge code. @@ -3234,6 +3288,7 @@ Badge renewal failed Tap for details The badge service refused the renewal: %1$s + The badge service refused the purchase: %1$s The badge service did not respond. The badge service could not be reached. The badge issued by the service cannot be verified. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml index 5bf3ab7754..055dbc2196 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/bg/strings.xml @@ -78,7 +78,7 @@ Позволи на вашите контакти да изпращат гласови съобщения. Всички ваши контакти ще останат свързани. Актуализацията на профила ще бъде изпратена до вашите контакти. Винаги включен - Android Keystore се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи. + Android Keystore%s се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи. Създаен беше празен профил за чат с предоставеното име и приложението се отвари както обикновено. Приложението може да получава известия само когато работи, няма да се стартира услуга във фонов режим Икона на приложението diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/bn/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/bn/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml index 9763bbaf88..4e9fa56bc0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ca/strings.xml @@ -125,7 +125,7 @@ Icona aplicació Desenfocar els mitjans Trucades - Android Keystore s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni. + Android Keystore%s s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni. Android Keystore s\'utilitzarà per emmagatzemar de manera segura la frase de contrasenya després de reiniciar l\'aplicació o canviar la frase de contrasenya; permetrà rebre notificacions. No es pot accedir a Keystore per desar la contrasenya de la base de dades Tingueu en compte: NO podreu recuperar ni canviar la contrasenya si la perdeu.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/cs/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/cs/strings.xml index 1d404ef94b..a80fd14a2f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/cs/strings.xml @@ -280,7 +280,7 @@ Aktualizovat přístupovou frázi Zadejte prosím správnou aktuální přístupovou frázi. Databáze chatu není šifrována - nastavte přístupovou frázi pro její ochranu. - K bezpečnému uložení přístupové fráze slouží úložiště klíčů Android - umožňuje fungování služby oznámení. + K bezpečnému uložení přístupové fráze slouží úložiště klíčů Android%s - umožňuje fungování služby oznámení. Upozornění: pokud přístupovou frázi ztratíte, NEBUDE možné ji obnovit ani změnit.]]> Databáze bude zašifrována a přístupová fráze bude uložena v úložišti klíčů. Přístupovou frázi uložte bezpečně, v případě její ztráty ji NEBUDE možné změnit. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/da/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/da/strings.xml index 9706c1b552..0d123e36c2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/da/strings.xml @@ -147,7 +147,7 @@ Brug altid privat routing. Brug altid relæ og %d andre begivenheder - Android Keystore bruges til sikkert at opbevare adgangssæt - det giver Notification Service mulighed for at arbejde. + Android Keystore%s bruges til sikkert at opbevare adgangssæt - det giver Notification Service mulighed for at arbejde. Android Keystore vil blive brugt til sikkert at gemme adgangssæt, når du genstarter appen eller skifter adgangssætning - det giver mulighed for at modtage meddelelser. En tom chatprofil med det angivne navn oprettes, og appen åbnes som sædvanligt. En ny tilfældig profil deles. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/de/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/de/strings.xml index ccc7732a18..c6157f1eeb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/de/strings.xml @@ -583,7 +583,7 @@ Datenbank-Passwort aktualisieren Bitte geben Sie das korrekte, aktuelle Passwort ein. Ihre Chat-Datenbank ist nicht verschlüsselt. Bitte legen Sie ein Passwort fest, um sie zu schützen. - Der Android-Keystore wird verwendet, um das Passwort sicher zu speichern. Dies ermöglicht die ordentliche Funktion des Benachrichtigungsdienstes. + Der Android-Keystore%s wird verwendet, um das Passwort sicher zu speichern. Dies ermöglicht die ordentliche Funktion des Benachrichtigungsdienstes. Die Datenbank wird mit einem zufälligen Passwort verschlüsselt, Sie können es ändern. Bitte beachten Sie: Sie können das Passwort NICHT wiederherstellen oder ändern, wenn Sie es vergessen haben oder verlieren.]]> Der Android-Keystore wird verwendet, um das Passwort sicher zu speichern, nachdem Sie die App neu gestartet oder das Passwort geändert haben – dies ermöglicht den Empfang von Benachrichtigungen. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/el/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/el/strings.xml index 30b5dad519..caff251b2c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/el/strings.xml @@ -242,7 +242,7 @@ Κακό μήνυμα hash Θάμπωση των μέσων Βάση δεδομένων συνομιλίας - Το Android Keystore χρησιμοποιείται για την ασφαλή αποθήκευση της φράσης πρόσβασης - επιτρέπει την υπηρεσία ειδοποιήσεων να λειτουργεί. + Το Android Keystore%s χρησιμοποιείται για την ασφαλή αποθήκευση της φράσης πρόσβασης - επιτρέπει την υπηρεσία ειδοποιήσεων να λειτουργεί. αποκλεισμένος Αποκλεισμένος από τον διαχειριστή Δεν είναι δυνατή η κλήση επαφής diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/es/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/es/strings.xml index 43e516561a..079d9cfc1b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/es/strings.xml @@ -51,7 +51,7 @@ Se eliminarán todos los chats y mensajes. ¡No puede deshacerse! Aceptar Se permiten mensajes temporales. - Android Keystore se usará para almacenar la frase de contraseña de forma segura - permite que el servicio de notificaciones funcione. + Android Keystore%s se usará para almacenar la frase de contraseña de forma segura - permite que el servicio de notificaciones funcione. Añadir perfil Color Permites que tus contactos eliminan irreversiblemente los mensajes enviados. (24 horas) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/fa/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/fa/strings.xml index 65f1769787..eb187475dd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/fa/strings.xml @@ -994,7 +994,7 @@ رمزنگاری به‌روزرسانی عبارت عبور پایگاه داده تعیین عبارت عبور - از مخزن کلید اندروید برای ذخیره امن عبارت عبور استفاده می‌شود - به سرویس اعلان اجازه عمل می‌دهد. + از مخزن کلید اندروید%s برای ذخیره امن عبارت عبور استفاده می‌شود - به سرویس اعلان اجازه عمل می‌دهد. باید هر بار که برنامه شروع می‌شود عبارت عبور را وارد کنید - در دستگاه ذخیره نمی‌شود. پایگاه داده رمزنگاری شده مخاطب بررسی شد diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/fi/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/fi/strings.xml index 5036d874d7..51a566a284 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/fi/strings.xml @@ -115,7 +115,7 @@ Poistetaanko tiedostot ja media\? %d tiedosto(a), joiden kokonaiskoko on %s Nykyinen tunnuslause… - Android Keystorea käytetään salalauseen turvalliseen tallentamiseen - se mahdollistaa ilmoituspalvelun toiminnan. + Android Keystorea%s käytetään salalauseen turvalliseen tallentamiseen - se mahdollistaa ilmoituspalvelun toiminnan. Tietokanta on salattu satunnaisella tunnuslauseella, voit muuttaa sitä. Tietokantavirhe Keskustelun avaamiseen tarvitaan tietokannan tunnuslause. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Bold.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Bold.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Bold.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Bold.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Italic.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Italic.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Italic.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Italic.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Light.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Light.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Light.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Light.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Medium.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Medium.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Medium.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Medium.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Regular.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Regular.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Regular.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Regular.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Semibold.ttf b/apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Semibold.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/Inter-Semibold.ttf rename to apps/multiplatform/common/src/commonMain/moko-resources/fonts/Inter-Semibold.ttf diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml index baa98ebf0b..dadd5ad300 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/fr/strings.xml @@ -584,7 +584,7 @@ Nouvelle phrase secrète… Confirmer la nouvelle phrase secrète… Mise à jour de la phrase secrète de la base de données - Le Keystore d\'Android est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner. + Le Keystore d\'Android%s est utilisé pour stocker en toute sécurité la phrase secrète - elle permet au service de notification de fonctionner. Vous devez saisir la phrase secrète à chaque fois que l\'application démarre - elle n\'est pas stockée sur l\'appareil. Chiffrer la base de données \? Changer la phrase secrète de la base de données \? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hi/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/hi/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hr/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/hr/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml index 447dabcf3d..f6f7e48508 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/hu/strings.xml @@ -41,7 +41,7 @@ hivatkozáselőnézet visszavonása Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. - Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. + Az Android Keystore-t%s a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. Hibás az üzenet kivonata Háttér Megjegyzés: az üzenet- és a fájlátjátszók SOCKS proxyn keresztül kapcsolódnak. A hívások pedig közvetlen kapcsolatot használnak.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/badge_investor.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/badge_investor.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/badge_investor.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/badge_legend.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/badge_legend.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/badge_legend.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/badge_supporter.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/badge_supporter.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/badge_supporter.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/decentralized_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/decentralized_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_symbol@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/flux_logo_symbol@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/flux_logo_symbol@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_box.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_box.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_box.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_box.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_account_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_account_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_group.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_group.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_group.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_link.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_link.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_link.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_link.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_photo.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_photo.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_photo.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_photo.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_add_reaction_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_add_reaction_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_agent.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_agent.svg new file mode 100644 index 0000000000..e03226d798 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_agent.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_all_inclusive.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_all_inclusive.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_all_inclusive.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_all_inclusive.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_alternate_email.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_alternate_email.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_alternate_email.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_back_ios_new.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_back_ios_new.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_back_ios_new.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_back_ios_new.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_circle_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_circle_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_circle_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_circle_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_downward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_downward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_downward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_downward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_up.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_up.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_drop_up.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_drop_up.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward_ios.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward_ios.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_forward_ios.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_forward_ios.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_outward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_outward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_outward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_outward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_upward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_upward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_upward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrow_upward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrows_left_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrows_left_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrows_left_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_arrows_left_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_article.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_article.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_article.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_article.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_at.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_at.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_at.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_attach_file_filled_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_attach_file_filled_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_avg_pace.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_avg_pace.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_avg_pace.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_avg_pace.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_back_hand.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_back_hand.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backspace.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backspace.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backspace.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backspace.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backup.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backup.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_backup.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_backup.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_2_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_2_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_2_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_2_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_3_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_3_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_battery_3_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_battery_3_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bedtime_moon.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bedtime_moon.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bedtime_moon.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bigtop_updates_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bluetooth.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bluetooth.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bluetooth.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bluetooth.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_blur_on.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_blur_on.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_blur_on.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_blur_on.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bolt_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_bolt_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_brand_awareness_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_brand_awareness_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_brand_awareness_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_brand_awareness_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_breaking_news.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_breaking_news.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_breaking_news.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_breaking_news.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cable.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cable.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cable.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cable.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_calendar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_calendar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_calendar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_calendar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_end_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_end_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_call_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_call_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_camera_enhance.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_camera_enhance.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_camera_enhance.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_camera_enhance.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cancel_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cancel_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cancel_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cancel_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_bubble.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_bubble.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_bubble.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_bubble.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_person.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_person.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_person.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chat_person.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_check_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_check_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_checklist.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_checklist.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_checklist.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right_2.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right_2.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_right_2.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_right_2.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_up.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_up.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chevron_up.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_chevron_up.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_close.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_close.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_close.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_close.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_code.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_code.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_code.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_code.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_collapse_all.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_collapse_all.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_contact_support.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_contact_support.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_contact_support.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_contact_support.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_copy.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_copy.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_copy.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_copy.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_paste.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_paste.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_content_paste.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_content_paste.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cube.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cube.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_cube.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_cube.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_database.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_database.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_database.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_database.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_delete_forever_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_delete_forever_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_desktop.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_desktop.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_desktop.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_desktop.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_dns.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_dns.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_dns.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_dns.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_disturb_on.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_disturb_on.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_disturb_on.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_disturb_on.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_touch.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_do_not_touch.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_done_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_done_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_done_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_done_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_double_check.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_double_check.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_double_check.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_double_check.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_download.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_download.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_download.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_download.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_draft_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_draft_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drag_handle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drag_handle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drag_handle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drive_folder_upload.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drive_folder_upload.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_drive_folder_upload.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_drive_folder_upload.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_note.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_note.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_edit_note.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_edit_note.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_electrical_services.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_electrical_services.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_electrical_services.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_electrical_services.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_engineering.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_engineering.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_engineering.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_error_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_error_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_expand_all.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_expand_all.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_filter_list.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_filter_list.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_filter_list.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_filter_list.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flag_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flag_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flip_camera_android_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flip_camera_android_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_flip_camera_android_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_flip_camera_android_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_closed_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_closed_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_open.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_open.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_open.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_open.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_pen.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_pen.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_folder_pen.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_folder_pen.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forum.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forum.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forum.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forum.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forward.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forward.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forward.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_forward.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_github.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_github.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_github.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_github.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_group_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_group_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_headphones.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_headphones.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_headphones.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_headphones.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_heart@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_heart@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_heart@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_heart@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_help_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_help_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_history.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_history.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_history.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_history.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_id_card.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_id_card.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_id_card.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_id_card.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_image_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_info.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_info.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_info.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_info.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_inventory_2.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_inventory_2.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_inventory_2.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_inventory_2.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ios_share.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ios_share.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ios_share.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ios_share.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_arrow_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_arrow_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_arrow_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_arrow_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_keyboard_voice_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_keyboard_voice_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_label_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_label_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_light_mode.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_light_mode.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_light_mode.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_light_mode.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lightbulb.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lightbulb.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lightbulb.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lightbulb.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_link.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_link.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_link.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_link.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_open_right.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_lock_open_right.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_lock_open_right.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_login.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_login.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_login.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_login.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_logout.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_logout.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_logout.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_logout.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mail_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mail_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_manage_accounts.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_manage_accounts.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_manage_accounts.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_manage_accounts.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_maps_ugc.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_maps_ugc.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_maps_ugc.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_maps_ugc.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mark_chat_unread.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mark_chat_unread.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mark_chat_unread.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mark_chat_unread.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_match_case.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_match_case.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_match_case.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_match_case.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_menu.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_menu.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_menu.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_menu.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mic_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mic_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_3.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_3.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_3.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_3.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_4.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_4.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_mobile_4.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_mobile_4.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_horiz.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_horiz.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_horiz.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_horiz.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_vert.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_vert.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_more_vert.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_more_vert.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_music_note.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_music_note.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_music_note.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_music_note.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_note_add.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_note_add.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_note_add.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_note_add.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notification_important_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notification_important_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_notifications_off_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_notifications_off_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_open_in_new.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_open_in_new.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_open_in_new.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_open_in_new.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outbound.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outbound.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outbound.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outline_terminal.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outline_terminal.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_outline_terminal.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_outline_terminal.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_palette.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_palette.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_palette.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_palette.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pause_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pause_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pause_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pause_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_pending_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_pending_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_add_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_edit.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_edit.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_edit.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_person_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_bluetooth_speaker.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_bluetooth_speaker.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_bluetooth_speaker.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_bluetooth_speaker.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_in_talk_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_in_talk_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_phone_in_talk_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_phone_in_talk_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_camera.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_camera.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_camera.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_camera.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_photo_library_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_play_arrow_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_play_arrow_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_play_arrow_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_play_arrow_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_power_settings_new.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_power_settings_new.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_power_settings_new.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_power_settings_new.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_priority_high.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_priority_high.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_priority_high.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_priority_high.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code_scanner.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_qr_code_scanner.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_question_mark.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_question_mark.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_question_mark.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_question_mark.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radio_button_unchecked.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radio_button_unchecked.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radio_button_unchecked.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radio_button_unchecked.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_1_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_1_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_1_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_1_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_2_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_2_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_2_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_2_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_3_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_3_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_3_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_3_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_4_bar.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_4_bar.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_radiowaves_up_forward_4_bar.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_radiowaves_up_forward_4_bar.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_redeem.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_redeem.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_redeem.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_redeem.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_refresh.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_refresh.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_refresh.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_repeat_one.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_repeat_one.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_repeat_one.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_repeat_one.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_replay.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_replay.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_replay.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_replay.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_reply.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_reply.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_reply.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_reply.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_report_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_report_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_restart_alt.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_restart_alt.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_restart_alt.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_restart_alt.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ring_volume.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ring_volume.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_ring_volume.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_ring_volume.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_safety_divider.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_safety_divider.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_safety_divider.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_safety_divider.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_schedule_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_schedule_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search_500.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search_500.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_search_500.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_search_500.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_security.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_security.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_security.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_security.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_backup_restore.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_backup_restore.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_backup_restore.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_backup_restore.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_ethernet.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_ethernet.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_ethernet.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_ethernet.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_phone.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_phone.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_settings_phone.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_settings_phone.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_share_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_share_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_shield.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_shield.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_shield.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_shield.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_dark@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_dark@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_dark@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_dark@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot_light.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_dot_light.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_light.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_simplex_tray_light.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smart_display.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smart_display.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smart_display.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smart_display.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone_300.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_smartphone_300.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_smartphone_300.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_star_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_star_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_start.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_start.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_start.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_start.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_stop_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_stop_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_stop_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_stop_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_supervised_user_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_supervised_user_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_supervised_user_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_supervised_user_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_swap_horizontal_circle_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_swap_horizontal_circle_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_sync_problem.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_sync_problem.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_sync_problem.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_tag.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_tag.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_tag.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_tag.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_task.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_task.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_task.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_task.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_theater_comedy_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_theater_comedy_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_timer_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_timer_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toast.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toast.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toast.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toast.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toggle_on.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toggle_on.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_toggle_on.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_toggle_on.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_translate.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_translate.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_translate.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_translate.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_travel_explore.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_travel_explore.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_travel_explore.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_travel_explore.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upgrade.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upgrade.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upgrade.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upgrade.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upload_file.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upload_file.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_upload_file.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_upload_file.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_usb.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_usb.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_usb.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_usb.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_missing.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_missing.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_missing.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_missing.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_user.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_user.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_verified_user.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_verified_user.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_videocam_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_videocam_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_visibility_off_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_visibility_off_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_down.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_down.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_down.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_down.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_up.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_up.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_volume_up.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_volume_up.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_off_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_off_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_vpn_key_off_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_vpn_key_off_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_warning_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_warning_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_waving_hand.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_waving_hand.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_waving_hand.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_waving_hand.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_off.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_off.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_off.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_tethering.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_tethering.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_wifi_tethering.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_wifi_tethering.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled_padded.svg b/apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled_padded.svg similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/ic_work_filled_padded.svg rename to apps/multiplatform/common/src/commonMain/moko-resources/images/ic_work_filled_padded.svg diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/icon_foreground_common@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/icon_foreground_common@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/icon_foreground_common@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/icon_foreground_common@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/logo@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/logo@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/logo@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/logo@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/logo_light@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/logo_light@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/logo_light@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/logo_light@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/privacy@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/privacy@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1.5x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1.5x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1.5x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1.5x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@1x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@1x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@2x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@2x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@2x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@3x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@3x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@3x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/shield@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/shield@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/shield@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/shield@4x.png diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png new file mode 100644 index 0000000000..f916e43ea9 Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@1x.png differ diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png new file mode 100644 index 0000000000..bb35878f0c Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@2x.png differ diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@3x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@3x.png new file mode 100644 index 0000000000..c55f481b36 Binary files /dev/null and b/apps/multiplatform/common/src/commonMain/moko-resources/images/vertical_logo@3x.png differ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_cats@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_cats@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_cats@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_cats@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_flowers@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_flowers@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_flowers@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_flowers@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_hearts@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_hearts@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_hearts@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_hearts@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_kids@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_kids@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_kids@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_kids@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_school@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_school@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_school@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_school@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_travel@4x.png b/apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_travel@4x.png similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/images/wallpaper_travel@4x.png rename to apps/multiplatform/common/src/commonMain/moko-resources/images/wallpaper_travel@4x.png diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml index 392c935575..29ebaac8d3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/in/strings.xml @@ -987,7 +987,7 @@ Tema Kirim tanda terima kiriman ke Hal ini dapat terjadi ketika Anda atau koneksi Anda menggunakan cadangan basis data lama. - Android Keystore digunakan untuk menyimpan frasa sandi dengan aman - memungkinkan layanan notifikasi berfungsi. + Android Keystore%s digunakan untuk menyimpan frasa sandi dengan aman - memungkinkan layanan notifikasi berfungsi. Hapus Enkripsi Pesan diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/it/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/it/strings.xml index 9b4e775685..9ae56f9d36 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/it/strings.xml @@ -256,7 +256,7 @@ Errore nell\'importazione del database della chat Nome completo del gruppo: Backup dei dati dell\'app - L\'archivio chiavi di Android è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. + L\'archivio chiavi di Android%s è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. Permetti ai tuoi contatti di inviare messaggi vocali. Database della chat eliminato Icona app diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/iw/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/iw/strings.xml index e42232f93a..8088ae4f23 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/iw/strings.xml @@ -51,7 +51,7 @@ לאפשר הודעות קוליות\? אפשר לאנשי קשר לשלוח הודעות קוליות. תמיד פעיל - ישנו שימוש ב־Android Keystore כדי לאחסן בבטחה את הסיסמה – דבר המאפשר לשירות ההתראות לעבוד. + ישנו שימוש ב־Android Keystore%s כדי לאחסן בבטחה את הסיסמה – דבר המאפשר לשירות ההתראות לעבוד. Android Keystore יאחסן בבטחה את הסיסמה לאחר הפעלה מחדש של האפליקציה או שינוי הסיסמה – דבר המאפשר קבלת התראות. גיבוי נתוני האפליקציה סמל האפליקציה diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ja/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ja/strings.xml index 0789cdcc69..c5cd469d81 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ja/strings.xml @@ -61,7 +61,7 @@ メンバーへのダイレクトメッセージを許可 音声メッセージの送信を許可 アクティブの時のみに通知が出ます。バックグラウンド通知サービスは起動されません。 - Androidキーストアはパスフレーズの保管に使われます。通知機能に必要です。 + Androidキーストア%sはパスフレーズの保管に使われます。通知機能に必要です。 再起動時とパスフレーズ変更時にAndroidキーストアがパスフレーズの保管に使われます。通知機能に必要です。 通話に応答 アプリのアイコン diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml index 880d328df6..01812e23e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ko/strings.xml @@ -148,7 +148,7 @@ 대화 상대가 허용하는 경우에만 영구적인 메시지 삭제를 허용합니다. (24 시간) 모든 대화 상대가 연결된 상태로 유지됩니다. 항상 켜기 - Android 암호 저장소는 암호를 안전하게 저장하는 데 사용됩니다 - 알림 서비스가 작동할 수 있습니다. + Android 암호 저장소%s는 암호를 안전하게 저장하는 데 사용됩니다 - 알림 서비스가 작동할 수 있습니다. 앱을 다시 시작하거나 암호를 변경한 후 Android 암호 저장소를 사용하여 암호를 안전하게 저장합니다. - 알림을 받을 수 있습니다. 앱이 실행 중일 때만 알림을 받을 수 있으며, 백그라운드 서비스는 시작되지 않습니다. 앱 데이터 백업 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ku/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ku/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml index 87144b710d..1eef42dd4e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/lt/strings.xml @@ -523,7 +523,7 @@ Visi programėlės duomenys bus ištrinti. Sukuriamas tuščias pokalbių profilis nurodytu pavadinimu ir programėlė atveriama kaip įprasta. Programėlė - Saugiam slaptafrazės saugojimui yra naudojama „Android Keystore“ – tai įgalina pranešimų tarnybą veikti. + Saugiam slaptafrazės saugojimui yra naudojama „Android Keystore“%s – tai įgalina pranešimų tarnybą veikti. Papildoma antrinė spalva Papildomas akcentavimas Leisti siųsti failus ir mediją. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/lv/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/lv/strings.xml index b045044925..b58411035a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/lv/strings.xml @@ -1672,7 +1672,7 @@ Iestatīt frāzi Ievadiet pareizo pašreizējo frāzi Datubāze nav šifrēta - Atslēgu glabātuve tiek droši glabāta + Atslēgu glabātuve%s tiek droši glabāta Iestatījumi tiek glabāti parastā tekstā Šifrēts ar nejaušu frāzi diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ml/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ml/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/nb-rNO/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/nb-rNO/strings.xml index 65614f25aa..859a05933c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/nb-rNO/strings.xml @@ -117,7 +117,7 @@ Bruk alltid privat ruting. Bruk alltid relé og %d andre hendelser - Android Keystore brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer. + Android Keystore%s brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer. Android Keystore brukes til å trygt lagre passordet ditt etter at du restarter appen eller bytter passord - det gjør at du kan motta varsler. En tom chat-profil med navnet du har valgt vil bli laget, og appen åpnes som vanlig. En ny tilfeldig profil vil bli delt. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/night/themes.xml b/apps/multiplatform/common/src/commonMain/moko-resources/night/themes.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/night/themes.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/night/themes.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml index 988d0e73cc..708f938ad9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/nl/strings.xml @@ -79,7 +79,7 @@ Geluid uit Back-up van app gegevens Beantwoord oproep - Android Keychain wordt gebruikt om het wachtwoord veilig op te slaan, hierdoor kan de meldings service werken. + Android Keychain%s wordt gebruikt om het wachtwoord veilig op te slaan, hierdoor kan de meldings service werken. Android Keychain wordt gebruikt om het wachtwoord veilig op te slaan nadat u de app opnieuw hebt opgestart of het wachtwoord heeft gewijzigd, hiermee kunt u meldingen ontvangen. App build: %s App kan alleen meldingen ontvangen wanneer deze actief is, er wordt geen achtergrondservice gestart diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/pl/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/pl/strings.xml index b725bfab32..942df14f13 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/pl/strings.xml @@ -538,7 +538,7 @@ Aktualizuj Aktualizuj hasło do bazy danych Twoja baza danych czatu nie jest szyfrowana - ustaw hasło, aby ją chronić. - Android Keystore służy do bezpiecznego przechowywania hasła - umożliwia działanie usługi powiadomień. + Android Keystore%s służy do bezpiecznego przechowywania hasła - umożliwia działanie usługi powiadomień. Android Keystore będzie używany do bezpiecznego przechowywania hasła po ponownym uruchomieniu aplikacji lub zmianie hasła - pozwoli to na otrzymywanie powiadomień. Uwaga: NIE będziesz w stanie odzyskać ani zmienić hasła, jeśli je zgubisz.]]> Nie można uzyskać dostępu do Keystore w celu zapisania hasła bazy danych diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/pt-rBR/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/pt-rBR/strings.xml index c353ad2a2f..679adb19e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/pt-rBR/strings.xml @@ -54,7 +54,7 @@ Chamadas na tela de bloqueio: Áudio ligado Banco de dados do chat importado - O Android Keystore é usado para armazenar a senha com segurança. Isso permite que o serviço de notificações funcione. + O Android Keystore%s é usado para armazenar a senha com segurança. Isso permite que o serviço de notificações funcione. O Android Keystore será usado para armazenar a senha com segurança após você reiniciar o aplicativo ou alterar a senha, permitindo continuar recebendo notificações. Não é possível acessar a Keystore para salvar a senha do banco de dados O chat está parado diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/pt/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/pt/strings.xml index 8b43d5295d..2c84318687 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/pt/strings.xml @@ -166,7 +166,7 @@ Mensagens de voz proibidas! Mensagem de voz (%1$s) Versão da aplicação: v%s - O Android Keystore é usado para armazenar com segurança a senha - permite que o serviço de notificações funcione. + O Android Keystore%s é usado para armazenar com segurança a senha - permite que o serviço de notificações funcione. O Android Keystore será usado para armazenar com segurança a senha depois de voçê reiniciar a aplicação ou alterar a senha - irá permitir receber notificações. As notificações serão entregues apenas até à aplicação parar! Compilação da aplicação: %s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ro/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ro/strings.xml index 8c7651fecd..f332586376 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ro/strings.xml @@ -93,7 +93,7 @@ Se alătură deja grupului! Mereu pornit Un nou profil aleatoriu va fi distribuit. - Android Keystore este folosit pentru a stoca în siguranță parola. Acest lucru permite funcționarea serviciului de notificări. + Android Keystore%s este folosit pentru a stoca în siguranță parola. Acest lucru permite funcționarea serviciului de notificări. și %d alte evenimente Răspunde la apel Android Keystore va fi folosit pentru a stoca în siguranță parola după ce repornești aplicația sau schimbi parola — acest lucru va permite primirea de notificări. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/ru/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/ru/strings.xml index 6f43e046d8..144e81471d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/ru/strings.xml @@ -585,7 +585,7 @@ Поменять пароль Пожалуйста, введите правильный пароль. База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные. - Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. + Android Keystore%s используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. База данных зашифрована случайным паролем, Вы можете его поменять. Внимание: Вы не сможете восстановить или поменять пароль, если потеряете его.]]> Пароль базы данных будет безопасно сохранён в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/sk/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/sk/strings.xml index 61b75fcf90..77bd862259 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/sk/strings.xml @@ -651,7 +651,7 @@ Nastavte prístupovú frázu pre export Aby ste odhalili svoj skrytý profil, zadajte celé heslo do vyhľadávacieho poľa na stránke profilov chatov. Vaše prihlasovacie údaje môžu byť zaslané nešifrované. - Android Keystore je použitý na bezpečné uloženie prístupovej frázy - umožňuje to fungovanie služby oznámení. + Android Keystore%s je použitý na bezpečné uloženie prístupovej frázy - umožňuje to fungovanie služby oznámení. Android Keystore bude použitý na bezpečné uloženie prístupovej frázy po reštarte aplikácie alebo zmene prístupovej frázy - umožní to fungovanie služby oznámení. Upozornenie: ak stratíte vašu prístupovú frázu, NEBUDE možné ju obnoviť ani zmeniť.]]> Zmeniť prístupovú frázu k databáze? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/sv/strings.xml similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/sv/strings.xml diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/th/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/th/strings.xml index 9e782ee24f..73ee88a3ed 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/th/strings.xml @@ -23,7 +23,7 @@ เน้นสีเพิ่มเติม ไอคอนแอป รหัสผ่านแอป - Android Keystore ใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัย - ซึ่งจะช่วยให้บริการแจ้งเตือนทำงานได้ + Android Keystore%s ใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัย - ซึ่งจะช่วยให้บริการแจ้งเตือนทำงานได้ Android Keystore จะถูกใช้เพื่อจัดเก็บรหัสผ่านอย่างปลอดภัยหลังจากที่คุณรีสตาร์ทแอปหรือเปลี่ยนรหัสผ่าน - ซึ่งจะอนุญาตให้รับบริการแจ้งเตือนได้ รุ่นแอป: %s การสํารองข้อมูลแอป diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/tr/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/tr/strings.xml index 30414e244c..989782053d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/tr/strings.xml @@ -665,7 +665,7 @@ Bağlanma isteklerini otomatik kabul et Uyarı: Bazı verileri kaybedebilirsin! Tüm kişileriniz bağlı kalacaktır. Profil güncellemesi kişilerinize gönderilecektir. - Android Keystore parolayı güvenli bir şekilde saklamak için kullanılır - bildirim hizmetinin çalışmasını sağlar. + Android Keystore%s parolayı güvenli bir şekilde saklamak için kullanılır - bildirim hizmetinin çalışmasını sağlar. Karşılama mesajı Karşılama mesajı Sesli mesajlar yasaktır. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/uk/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/uk/strings.xml index 4f49834c7f..67ed554185 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/uk/strings.xml @@ -25,7 +25,7 @@ Пароль застосунку замінено паролем самознищення. Резервне копіювання даних застосунку Додати на інший пристрій - Сховище ключів Android використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати. + Сховище ключів Android%s використовується для безпечного збереження ключової фрази - це дозволяє службі сповіщень працювати. Адміністратори можуть створювати посилання для приєднання до групи. Збірка додатку: %s Дозволити голосові повідомлення тільки за умови, що ваш контакт дозволяє їх. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/vi/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/vi/strings.xml index 9c63cff9ee..11e9080687 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/vi/strings.xml @@ -82,7 +82,7 @@ Đã tham gia nhóm rồi! Luôn sử dụng relay và %d sự kiện khác - Android Keystore được sử dụng để lưu trữ passphrase - nó cho phép dịch vụ thông báo hoạt động. + Android Keystore%s được sử dụng để lưu trữ passphrase - nó cho phép dịch vụ thông báo hoạt động. Android Keystore sẽ được sử dụng để lưu trữ passphrase một cách an toàn sau khi bạn khởi động lại ứng dụng hoặc thay đổi passphrase - nó cho phép tiếp nhận thông báo. Tất cả các liên hệ, cuộc hội thoại và tệp của bạn sẽ được mã hóa an toàn và tải lên từng phần tới các XFTP relay được chỉ định. Quản trị viên có thể chặn một thành viên khỏi tất cả. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/zh-rCN/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/zh-rCN/strings.xml index 75e67cca37..daa77e171e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/zh-rCN/strings.xml @@ -89,7 +89,7 @@ 为此删除聊天资料 删除数据库 在你重启应用程序或者更换密码后安卓密钥库系统用来安全地保存密码——来确保收到通知。 - 安卓密钥库系统用来安全地保存密码——来确保通知服务运作。 + 安卓密钥库系统%s用来安全地保存密码——来确保通知服务运作。 外观 应用程序版本 应用程序数据备份 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/zh-rTW/strings.xml similarity index 99% rename from apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml rename to apps/multiplatform/common/src/commonMain/moko-resources/zh-rTW/strings.xml index 5827cead70..c146c778a8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/zh-rTW/strings.xml @@ -94,7 +94,7 @@ 備份應用程式資料 應用程式圖示 已匯入對話資料庫 - Android 金鑰庫是用於安全地儲存密碼 - 確保通知推送服務的運作。 + Android 金鑰庫%s是用於安全地儲存密碼 - 確保通知推送服務的運作。 請注意:如果你忘記了密碼你將不能再次復原或修改密碼。]]> 當你重新啟動應用程式或修改密碼後, Android 金鑰庫將用來安全地儲存密碼 - 將允許接收訊息通知。 聊天已停止 diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index 7ab8d6fdd6..6fb3677a2e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -54,6 +54,8 @@ let inactiveCallMediaSources = { let activeCall; let notConnectedCall; let answerTimeout = 30000; +let reconnectTimeout = 30000; +let iceRestartInterval = 10000; var useWorker = false; var isDesktop = false; var localizedState = ""; @@ -75,7 +77,8 @@ const processCommand = (function () { return { peerConnectionConfig: { iceServers: iceServers !== null && iceServers !== void 0 ? iceServers : defaultIceServers, - iceCandidatePoolSize: 10, + // Chrome gathers no candidates on ICE restart when candidates pool is used + iceCandidatePoolSize: 0, encodedInsertableStreams, iceTransportPolicy: relay ? "relay" : "all", // needed for Android WebView >= 69 && <= 72 where default was "plan-b" which is incompatible with transceivers @@ -202,10 +205,14 @@ const processCommand = (function () { layout: (_c = notConnectedCall === null || notConnectedCall === void 0 ? void 0 : notConnectedCall.layout) !== null && _c !== void 0 ? _c : LayoutType.Default, cameraTrackWasSetBefore: localStream.getVideoTracks().length > 0, peerHasOldVersion: false, + receiveRestartSession, }; localOrPeerMediaSourcesChanged(call); await setupMediaStreams(call); let connectionTimeout = setTimeout(connectionHandler, answerTimeout); + let reconnectingTimeout; + let iceRestartTimeout; + let wasConnected = false; if (pc.connectionState) { pc.addEventListener("connectionstatechange", connectionStateChange); } @@ -216,30 +223,49 @@ const processCommand = (function () { async function connectionStateChange() { // "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView) // See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70) - if (pc.connectionState !== "failed") + if (pc.connectionState !== "failed" || wasConnected) connectionHandler(); } - async function connectionHandler() { + async function connectionHandler(reconnectTimedOut = false) { var _a; + let state = (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" + ? pc.iceConnectionState + : pc.iceConnectionState == "completed" + ? "connected" + : "connecting"); /* webView 69-70 doesn't have connectionState yet */ + if (wasConnected) { + if (reconnectTimedOut) { + reconnectingTimeout = undefined; + if (activeCall !== call || state == "connected") + return; + state = "disconnected"; + } + else if (state == "disconnected" || state == "failed") { + if (reconnectingTimeout !== undefined) + return; + reconnectingTimeout = setTimeout(() => connectionHandler(true), reconnectTimeout); + if (isIceRestarter()) + restartIce(); + state = "reconnecting"; + } + else if (state == "connecting" || state == "new") { + return; + } + } sendMessageToNative({ resp: { type: "connection", state: { - connectionState: (_a = pc.connectionState) !== null && _a !== void 0 ? _a : (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" - ? pc.iceConnectionState - : pc.iceConnectionState == "completed" - ? "connected" - : "connecting") /* webView 69-70 doesn't have connectionState yet */, + connectionState: state, iceConnectionState: pc.iceConnectionState, iceGatheringState: pc.iceGatheringState, signalingState: pc.signalingState, }, }, }); - if (pc.connectionState == "disconnected" || - pc.connectionState == "failed" || - (!pc.connectionState && (pc.iceConnectionState == "disconnected" || pc.iceConnectionState == "failed"))) { + if (state == "disconnected" || state == "failed") { clearConnectionTimeout(); + clearReconnecting(); if (pc.connectionState) { pc.removeEventListener("connectionstatechange", connectionStateChange); } @@ -251,8 +277,10 @@ const processCommand = (function () { } endCall(); } - else if (pc.connectionState == "connected" || (!pc.connectionState && pc.iceConnectionState == "connected")) { + else if (state == "connected") { clearConnectionTimeout(); + clearReconnecting(); + wasConnected = true; const stats = (await pc.getStats()); for (const stat of stats.values()) { const { type, state } = stat; @@ -278,6 +306,53 @@ const processCommand = (function () { connectionTimeout = undefined; } } + function clearReconnecting() { + clearTimeout(reconnectingTimeout); + reconnectingTimeout = undefined; + clearTimeout(iceRestartTimeout); + iceRestartTimeout = undefined; + } + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + function isIceRestarter() { + var _a; + return ((_a = pc.localDescription) === null || _a === void 0 ? void 0 : _a.type) == "offer"; + } + async function restartIce() { + if (activeCall !== call || reconnectingTimeout === undefined) + return; + iceRestartTimeout = setTimeout(restartIce, iceRestartInterval); + // the previous restart offer is not answered yet + if (pc.signalingState != "stable") + return; + try { + const iceCandidates = getIceCandidates(pc, config); + const offer = await pc.createOffer({ iceRestart: true }); + await pc.setLocalDescription(offer); + sendMessageToNative({ resp: { type: "ice", session: serialize(offer), iceCandidates: await iceCandidates } }); + } + catch (e) { + console.log("ICE restart error", e); + } + } + async function receiveRestartSession(session, iceCandidates) { + const desc = parse(session); + const remoteDesc = new RTCSessionDescription(!webView69Or70() ? desc : adaptSdpToOldWebView(desc)); + if (desc.type == "offer" && !isIceRestarter()) { + await pc.setRemoteDescription(remoteDesc); + const localIceCandidates = getIceCandidates(pc, config); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + addIceCandidates(pc, iceCandidates); + sendMessageToNative({ resp: { type: "ice", session: serialize(answer), iceCandidates: await localIceCandidates } }); + } + else if (desc.type == "answer" && pc.signalingState == "have-local-offer") { + await pc.setRemoteDescription(remoteDesc); + addIceCandidates(pc, iceCandidates); + } + else { + throw new Error(`unexpected ICE restart ${desc.type} in ${pc.signalingState} state`); + } + } } function serialize(x) { return LZString.compressToBase64(JSON.stringify(x)); @@ -296,6 +371,8 @@ const processCommand = (function () { console.log("starting outgoing call - capabilities"); if (activeCall) endCall(); + // Stop a preview stream from an earlier pre-connect outgoing call being replaced (activeCall may be null here) + stopNotConnectedCall(); let localStream = null; try { localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video && (await browserHasCamera()), VideoCamera.User); @@ -332,7 +409,8 @@ const processCommand = (function () { if (activeCall) endCall(); // It can be already defined on Android when switching calls (if the previous call was outgoing) - notConnectedCall = undefined; + // Stop its preview tracks before clearing, otherwise camera/mic stay live + stopNotConnectedCall(); inactiveCallMediaSources.mic = true; inactiveCallMediaSources.camera = command.media == CallMediaType.Video; inactiveCallMediaSourcesChanged(inactiveCallMediaSources); @@ -436,7 +514,11 @@ const processCommand = (function () { break; case "ice": const remoteIceCandidates = parse(command.iceCandidates); - if (pc) { + if (activeCall && command.session) { + await activeCall.receiveRestartSession(command.session, remoteIceCandidates); + resp = { type: "ok" }; + } + else if (pc) { addIceCandidates(pc, remoteIceCandidates); resp = { type: "ok" }; } @@ -1131,6 +1213,13 @@ const processCommand = (function () { activeCall.localScreenStream.getTracks().forEach((track) => track.stop()); } } + // Call on any path that abandons notConnectedCall, otherwise its preview camera/mic tracks stay live. + function stopNotConnectedCall() { + if (notConnectedCall) { + notConnectedCall.localStream.getTracks().forEach((track) => track.stop()); + notConnectedCall = undefined; + } + } function resetVideoElements() { const videos = getVideoElements(); if (!videos) diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js index e6828817ee..be1c820b4e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -2,7 +2,7 @@ // Override defaults to enable worker on Chrome and Safari useWorker = typeof window.Worker !== "undefined"; isDesktop = true; -// Create WebSocket connection. +// Create WebSocket connection. location.search carries the per-call ?token=... capability required by the server. const socket = new WebSocket(`ws://${location.host}${location.search}`); socket.addEventListener("open", (_event) => { console.log("Opened socket"); @@ -192,4 +192,4 @@ function updateCallInfoView(state, description) { document.getElementById("state").innerText = state; document.getElementById("description").innerText = description; } -//# sourceMappingURL=ui.js.map +//# sourceMappingURL=ui.js.map \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/BadgePageUrlTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/BadgePageUrlTest.kt new file mode 100644 index 0000000000..c6baf89fcb --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/BadgePageUrlTest.kt @@ -0,0 +1,19 @@ +package chat.simplex.app + +import chat.simplex.common.views.badges.badgePageUrl +import kotlin.test.Test +import kotlin.test.assertTrue + +class BadgePageUrlTest { + @Test + fun pageEndsWithALinkBackWhenTheLinkReturnsToTheApp() { + val url = badgePageUrl(linkReturns = true) + assertTrue(url.endsWith("#/tier?app=true"), "url: $url") + } + + @Test + fun pageEndsWithACodeToPasteWhenNoLinkReturns() { + val url = badgePageUrl(linkReturns = false) + assertTrue(url.endsWith("#/tier?app=desktop"), "url: $url") + } +} diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ComposeEmojiTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ComposeEmojiTest.kt new file mode 100644 index 0000000000..da2fecff60 --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/ComposeEmojiTest.kt @@ -0,0 +1,42 @@ +package chat.simplex.app + +import androidx.compose.ui.text.TextRange +import chat.simplex.common.views.chat.ComposeMessage +import chat.simplex.common.views.chat.insertingEmoji +import kotlin.test.Test +import kotlin.test.assertEquals + +// use this command for testing: +// ./gradlew desktopTest +class ComposeEmojiTest { + @Test + fun testInsertIntoEmptyMessage() { + assertEquals(ComposeMessage("😀", TextRange(2)), ComposeMessage().insertingEmoji("😀")) + } + + @Test + fun testInsertAtCursor() { + assertEquals(ComposeMessage("hi😀 there", TextRange(4)), ComposeMessage("hi there", TextRange(2)).insertingEmoji("😀")) + assertEquals(ComposeMessage("😀hi", TextRange(2)), ComposeMessage("hi", TextRange(0)).insertingEmoji("😀")) + assertEquals(ComposeMessage("hi👍", TextRange(4)), ComposeMessage("hi").insertingEmoji("👍")) + } + + @Test + fun testReplaceSelection() { + assertEquals(ComposeMessage("say ❤️!", TextRange(6)), ComposeMessage("say love!", TextRange(4, 8)).insertingEmoji("❤️")) + // selection made right to left has start > end + assertEquals(ComposeMessage("say ❤️!", TextRange(6)), ComposeMessage("say love!", TextRange(8, 4)).insertingEmoji("❤️")) + } + + @Test + fun testInsertAfterEmoji() { + val flag = "🏴󠁧󠁢󠁷󠁬󠁳󠁿" + val withFlag = ComposeMessage(flag) + assertEquals(ComposeMessage(flag + "👩‍💻", TextRange(flag.length + "👩‍💻".length)), withFlag.insertingEmoji("👩‍💻")) + } + + @Test + fun testSelectionBeyondTextIsClamped() { + assertEquals(ComposeMessage("ab😀", TextRange(4)), ComposeMessage("ab", TextRange(5)).insertingEmoji("😀")) + } +} diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt new file mode 100644 index 0000000000..ccf78ba276 --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MarkdownBlocksTest.kt @@ -0,0 +1,133 @@ +package chat.simplex.app + +import chat.simplex.common.model.* +import chat.simplex.common.views.chat.item.itemDisplay +import chat.simplex.common.views.chat.item.segmentDisplayText +import chat.simplex.common.views.helpers.ParsedFormattedText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class MarkdownBlocksTest { + private val headingAndParagraph = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"Title "},{"format":{"type":"bold","singleStar":true},"text":"x"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"text":"Para a"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"text":"b"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":" \n"},{"text":"c"}],"markdownBlocks":[{"type":"heading","level":1,"content":{"from":1,"to":3}},{"type":"paragraph","content":{"from":4,"to":9}}]}""") + + private val nestedList = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"listMark"}},"text":"- "},{"text":"x"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"indent"}},"text":" "},{"text":"y"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"listMark"}},"text":" - "},{"text":"z"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"listMark","number":"2."}},"text":"2. "},{"text":"w"}],"markdownBlocks":[{"type":"list","items":[{"content":{"from":1,"to":5},"items":[{"content":{"from":7,"to":8},"items":[]}]},{"number":"2.","content":{"from":10,"to":11},"items":[]}]}]}""") + + private val quote = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q1"},{"format":{"type":"markup","token":{"type":"softBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"text":"q2"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":">"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"quoteMark"}},"text":"> "},{"format":{"type":"bold","singleStar":false},"text":"q3"}],"markdownBlocks":[{"type":"quote","content":{"from":1,"to":10}}]}""") + + private val codeBlockAndRule = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"fenceMark","language":"bash"}},"text":"```bash"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"ls -l"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"text":"pwd"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"fenceMark"}},"text":"```"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"ruleMark"}},"text":"---"}],"markdownBlocks":[{"type":"codeBlock","language":"bash","content":{"from":2,"to":6}},{"type":"rule","mark":{"from":9,"to":10}}]}""") + + private val tableAligned = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"a"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"b"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"c"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"d"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"delimiterRow","columns":["none","left","right","center"]}},"text":"|---|:--|--:|:-:|"},{"format":{"type":"markup","token":{"type":"hardBreak"}},"text":"\n"},{"format":{"type":"markup","token":{"type":"rowMark"}},"text":"| "},{"text":"xxx"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"format":{"type":"bold","singleStar":false},"text":"yyy"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" | "},{"text":"zzz"},{"format":{"type":"markup","token":{"type":"cellMark"}},"text":" |"}],"markdownBlocks":[{"type":"table","columns":["none","left","right","center"],"header":[{"from":1,"to":2},{"from":3,"to":4},{"from":5,"to":6},{"from":7,"to":8}],"rows":[[{"from":13,"to":14},{"from":15,"to":16},{"from":17,"to":18}]]}]}""") + + private val gaps = parsed("""{"formattedText":[{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":2}},"text":"\n\n"},{"text":"a"},{"format":{"type":"markup","token":{"type":"paragraphBreak","lineBreaks":3}},"text":"\n\n\n"},{"format":{"type":"markup","token":{"type":"headingMark","level":1}},"text":"# "},{"text":"h"},{"format":{"type":"markup","token":{"type":"lineBreak"}},"text":"\n"}],"markdownBlocks":[{"type":"paragraph","content":{"from":1,"to":2}},{"type":"heading","level":1,"content":{"from":4,"to":5}}]}""") + + private val escapes = parsed("""{"formattedText":[{"text":"text "},{"format":{"type":"escape"},"text":"\\"},{"text":"*not"},{"format":{"type":"escape"},"text":"\\"},{"text":"*\nmore"}]}""") + + @Test + fun testDecodeTokens() { + val tokens = (headingAndParagraph.formattedText!! + nestedList.formattedText!! + codeBlockAndRule.formattedText!! + tableAligned.formattedText!! + gaps.formattedText!!) + .mapNotNull { (it.format as? Format.Markup)?.token } + assertEquals(1, tokens.filterIsInstance().first().level) + assertEquals(listOf(null, null, "2."), tokens.filterIsInstance().map { it.number }) + assertEquals(listOf("bash", null), tokens.filterIsInstance().map { it.language }) + assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), tokens.filterIsInstance().first().columns) + assertEquals(listOf(2, 3), tokens.filterIsInstance().map { it.lineBreaks }) + listOf( + BlockToken.LineBreak::class, BlockToken.SoftBreak::class, BlockToken.HardBreak::class, BlockToken.Indent::class, + BlockToken.RowMark::class, BlockToken.CellMark::class, BlockToken.RuleMark::class + ).forEach { kind -> assert(tokens.any { kind.isInstance(it) }) { "no $kind" } } + assertIs((quote.formattedText!![0].format as Format.Markup).token) + val bold = headingAndParagraph.formattedText!!.mapNotNull { it.format as? Format.Bold } + quote.formattedText!!.mapNotNull { it.format as? Format.Bold } + assertEquals(listOf(true, false), bold.map { it.singleStar }) + assertEquals(listOf("text ", "", "*not", "", "*\nmore"), escapes.formattedText!!.map(::display)) + assertNull(escapes.markdownBlocks) + } + + @Test + fun testDecodeBlocks() { + val heading = assertIs(headingAndParagraph.markdownBlocks!![0]) + assertEquals(1, heading.level) + assertEquals(1 to 3, heading.content.pair) + assertEquals(4 to 9, assertIs(headingAndParagraph.markdownBlocks!![1]).content.pair) + val list = assertIs(nestedList.markdownBlocks!!.single()) + assertEquals(listOf(null, "2."), list.items.map { it.number }) + assertEquals(7 to 8, list.items[0].items.single().content.pair) + assertEquals(1 to 10, assertIs(quote.markdownBlocks!!.single()).content.pair) + val code = assertIs(codeBlockAndRule.markdownBlocks!![0]) + assertEquals("bash", code.language) + assertEquals(2 to 6, code.content.pair) + assertEquals(9 to 10, assertIs(codeBlockAndRule.markdownBlocks!![1]).mark.pair) + val table = assertIs(tableAligned.markdownBlocks!!.single()) + assertEquals(listOf(TableAlign.None, TableAlign.Left, TableAlign.Right, TableAlign.Center), table.columns) + assertEquals(listOf(1 to 2, 3 to 4, 5 to 6, 7 to 8), table.header.map { it.pair }) + assertEquals(listOf(listOf(13 to 14, 15 to 16, 17 to 18)), table.rows.map { row -> row.map { it.pair } }) + } + + @Test + fun testBlockTextRanges() { + assertEquals(listOf(1 to 3, 4 to 9), texts(headingAndParagraph).map { it.first }) + assertEquals(listOf(1 to 5, 7 to 8, 10 to 11), texts(nestedList).map { it.first }) + assertEquals(listOf(1 to 10), texts(quote).map { it.first }) + assertEquals(listOf(2 to 6), texts(codeBlockAndRule).map { it.first }) + assertEquals( + listOf(1 to 2 to false, 3 to 4 to true, 5 to 6 to true, 7 to 8 to true, 13 to 14 to false, 15 to 16 to true, 17 to 18 to true), + texts(tableAligned) + ) + } + + @Test + fun testListEntryLevels() { + val list = assertIs(nestedList.markdownBlocks!!.single()) + assertEquals(listOf(1 to 0, 7 to 1, 10 to 0), listEntries(list.items, 0).map { (entry, level) -> entry.content.from to level }) + } + + @Test + fun testHeadingSegments() { + assertEquals(listOf(1, 2), headingSegments(9, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + assertEquals(listOf(4), headingSegments(6, gaps.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + assertEquals(listOf(1), headingSegments(2, headingAndParagraph.markdownBlocks!!).withIndex().filter { it.value }.map { it.index }) + } + + @Test + fun testTokenTexts() { + val tokens = listOf( + BlockToken.SoftBreak(), BlockToken.LineBreak(), BlockToken.HardBreak(), BlockToken.ParagraphBreak(3), BlockToken.Indent(), + BlockToken.HeadingMark(2), BlockToken.ListMark("1."), BlockToken.ListMark(null), BlockToken.QuoteMark(), BlockToken.FenceMark(null), + BlockToken.RowMark(), BlockToken.CellMark(), BlockToken.DelimiterRow(listOf(TableAlign.None)), BlockToken.RuleMark() + ) + assertEquals(listOf(" ", "\n", "\n", "\n\n\n", "", "", "", "", "", "", "", "", "", ""), tokens.map { it.drawnText }) + assertEquals(listOf(" ", "\n", " ", "\n", "", "", "1. ", "• ", "", "", "", " ", "", ""), tokens.map { it.previewText }) + assertEquals("", display(FormattedText("\\", Format.Escape()))) + assertEquals("Title x\nPara a b c", preview(headingAndParagraph)) + assertEquals("• x y\n• z\n2. w", preview(nestedList)) + } + + @Test + fun testItemDisplay() { + val item = ChatItem.getSampleData(text = "> q1\n> q2\n>\n> **q3**").copy(formattedText = quote.formattedText, markdownBlocks = quote.markdownBlocks) + val display = itemDisplay(item, SimplexLinkMode.DESCRIPTION)!! + assertEquals("q1 q2\n\nq3", display.parts.joinToString("") { it.first }) + assertEquals(listOf(0), display.bases) + val table = item.copy(formattedText = tableAligned.formattedText, markdownBlocks = tableAligned.markdownBlocks) + val tableDisplay = itemDisplay(table, SimplexLinkMode.DESCRIPTION)!! + assertEquals("a\tb\tc\td\nxxx\tyyy\tzzz", tableDisplay.parts.joinToString("") { it.first }) + assertEquals(listOf(0, 2, 4, 6, 8, 12, 16), tableDisplay.bases) + val plain = item.copy(formattedText = escapes.formattedText, markdownBlocks = null) + val plainDisplay = itemDisplay(plain, SimplexLinkMode.DESCRIPTION)!! + assertEquals(listOf("text " to "text ", "" to "\\", "*not" to "*not", "" to "\\", "*\nmore" to "*\nmore"), plainDisplay.parts) + assertEquals(listOf(0), plainDisplay.bases) + } + + private val SegmentRange.pair: Pair get() = from to to + + private fun parsed(s: String): ParsedFormattedText = json.decodeFromString(ParsedFormattedText.serializer(), s) + + private fun display(ft: FormattedText): String = segmentDisplayText(ft, null, SimplexLinkMode.DESCRIPTION) + + private fun texts(p: ParsedFormattedText): List, Boolean>> = + blockTextRanges(p.markdownBlocks!!).map { it.range.pair to it.inRow } + + private fun preview(p: ParsedFormattedText): String = + p.formattedText!!.joinToString("") { ft -> (ft.format as? Format.Markup)?.token?.previewText ?: ft.text } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt new file mode 100644 index 0000000000..067d4edfd6 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt @@ -0,0 +1,33 @@ +package chat.simplex.common + +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chatlist.isAppLink +import chat.simplex.common.views.chatlist.isConnectionLink +import java.awt.Desktop + +// A one-time link with its post-quantum key is about 2 KB, near 4 KB with a second such key; +// the bound caps what the app accepts from outside. +internal const val MAX_LINK_BYTES = 8192 + +internal fun isAcceptedLink(uri: String): Boolean = + uri.toByteArray(Charsets.UTF_8).size <= MAX_LINK_BYTES && (isAppLink(uri) || isConnectionLink(uri)) + +fun linkFromArgs(args: Array): String? = + args.singleOrNull()?.takeIf(::isAcceptedLink) + +// The link is stored before showWindow, so a failure to show the window cannot lose it. +internal fun openDesktopLink(uri: String) { + chatModel.appOpenUrl.value = chatModel.remoteHostId() to uri + showWindow() +} + +// macOS delivers a link as an Apple Event, never in args; the JDK queues it until a handler is set +fun installOpenUriHandler() { + if (!Desktop.isDesktopSupported()) return + val desktop = Desktop.getDesktop() + if (!desktop.isSupported(Desktop.Action.APP_OPEN_URI)) return + desktop.setOpenURIHandler { event -> + val uri = event.uri.toString() + if (isAcceptedLink(uri)) openDesktopLink(uri) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 41e81a6f60..77dad81aa4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -19,7 +19,7 @@ import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.TerminalView import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* @@ -259,11 +259,16 @@ private fun ApplicationScope.handleCloseRequest(closedByError: MutableState { lockHandle = result.lock singleInstanceLock = true - deleteShowFile() - startShowFileWatcher() + deleteStaleSignalFiles(dataDir.toPath(), lockAttemptTime) return true } LockResult.Failed -> { return true } LockResult.Taken -> { - // Ensure the signal file exists (createShowFile is a no-op if it does) - // and wait up to 1s for the primary's watcher to consume it. If still - // there after the wait, the primary is hung — let the user decide. - createShowFile() - val deadline = System.currentTimeMillis() + 1000 - while (Files.exists(showPath) && System.currentTimeMillis() < deadline) { - try { Thread.sleep(50) } catch (_: InterruptedException) { break } - } - if (!Files.exists(showPath)) return false - val start = showSingleInstanceAlert() - if (start) deleteShowFile() - return start + if (desktopPlatform.isWindows()) allowPrimaryForeground() + return startDespiteRunningInstance(dataDir.toPath(), link, SIGNAL_WAIT_MILLIS, ::showSingleInstanceAlert) } } } @@ -83,20 +90,129 @@ private fun tryAcquireLock(): LockResult { } } -private fun deleteShowFile() { - try { Files.deleteIfExists(showPath) } catch (e: IOException) { - Log.w(TAG, "single-instance: cannot delete show file: ${e.message}") +// A signal still present after the wait means the running instance is hung, so the user decides. +internal fun startDespiteRunningInstance(dir: Path, link: String?, waitMillis: Long, askToStart: () -> Boolean): Boolean { + signalRunningInstance(dir, link) + val show = dir.resolve(SHOW_FILE) + val deadline = System.currentTimeMillis() + waitMillis + while (Files.exists(show) && System.currentTimeMillis() < deadline) { + try { Thread.sleep(SIGNAL_POLL_MILLIS) } catch (_: InterruptedException) { break } + } + if (!Files.exists(show) || !askToStart()) return false + // The running instance can recover while the alert is up; once it has taken the signal, it opens the link itself. + return try { + Files.deleteIfExists(show) + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot withdraw signal file: ${e.message}") + true } } -private fun createShowFile() { - try { Files.createFile(showPath) } catch (_: FileAlreadyExistsException) { - // Another duplicate already signalled; primary will pick it up. - } catch (e: IOException) { - Log.w(TAG, "single-instance: cannot create show file: ${e.message}") +private fun deleteSignalFile(path: Path) { + try { Files.deleteIfExists(path) } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot delete signal file: ${e.message}") } } +internal fun deleteStaleSignalFiles(dir: Path, lockAttemptTime: FileTime) { + val staleBefore = FileTime.from(lockAttemptTime.toInstant().minus(FILE_TIME_TOLERANCE)) + try { + // only a running instance takes a signal, so a taken file at startup was left by a crash + Files.deleteIfExists(dir.resolve(TAKEN_SHOW_FILE)) + Files.newDirectoryStream(dir, "$SHOW_FILE*").use { files -> + files.forEach { file -> + try { + if (Files.getLastModifiedTime(file) < staleBefore) Files.deleteIfExists(file) + } catch (_: NoSuchFileException) { + // a second process renamed its temp file meanwhile + } + } + } + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot delete stale signal files: ${e.message}") + } catch (e: DirectoryIteratorException) { + Log.w(TAG, "single-instance: cannot delete stale signal files: ${e.cause?.message}") + } +} + +// The temp file is owner-only on POSIX, as the link is a bearer secret, and the rename makes it appear whole. +internal fun signalRunningInstance(dir: Path, link: String?) { + var tmp: Path? = null + try { + tmp = Files.createTempFile(dir, SHOW_FILE, SHOW_TMP_SUFFIX) + Files.write(tmp, (link ?: "").toByteArray(Charsets.UTF_8)) + Files.move(tmp, dir.resolve(SHOW_FILE), ATOMIC_MOVE) + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot signal running instance: ${e.message}") + tmp?.let(::deleteSignalFile) + // an empty signal needs no free space and still brings the running instance forward + try { + Files.createFile(dir.resolve(SHOW_FILE)) + } catch (_: FileAlreadyExistsException) { + // another process has already signalled + } catch (createError: IOException) { + Log.w(TAG, "single-instance: cannot create signal file: ${createError.message}") + } + } +} + +// The signal is renamed before it is read, so a newer signal renamed over it meanwhile is not deleted unread. +internal fun takeSignal(dir: Path, move: (Path, Path) -> Unit = ::moveAtomically): ShowSignal? { + val taken = dir.resolve(TAKEN_SHOW_FILE) + try { + moveWithRetry(dir.resolve(SHOW_FILE), taken, move) + } catch (_: NoSuchFileException) { + return null + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot take signal file: ${e.message}") + return ShowSignal(null) + } + val bytes = try { + Files.newInputStream(taken).use { it.readNBytes(MAX_LINK_BYTES + 1) } + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot read signal file: ${e.message}") + null + } finally { + deleteSignalFile(taken) + } + return ShowSignal(bytes?.toString(Charsets.UTF_8)?.takeIf(::isAcceptedLink)) +} + +private fun moveAtomically(from: Path, to: Path) { + Files.move(from, to, ATOMIC_MOVE) +} + +// Another program, such as an antivirus scanner on Windows, can hold the file open for a moment. +private fun moveWithRetry(from: Path, to: Path, move: (Path, Path) -> Unit) { + try { + move(from, to) + } catch (e: NoSuchFileException) { + throw e + } catch (e: IOException) { + try { + Thread.sleep(SIGNAL_RETRY_MILLIS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + throw e + } + move(from, to) + } +} + +// Win32 ASFW_ANY lets every process take the foreground +private const val ASFW_ANY = -1 + +// Windows lets only the foreground process raise a window; the launching browser passed that right to this process. +private fun allowPrimaryForeground() { + val allowed = try { + NativeLibrary.getInstance("user32").getFunction("AllowSetForegroundWindow").invokeInt(arrayOf(ASFW_ANY)) != 0 + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, "single-instance: AllowSetForegroundWindow unavailable: ${e.message}") + return + } + if (!allowed) Log.w(TAG, "single-instance: AllowSetForegroundWindow refused") +} + private fun showSingleInstanceAlert(): Boolean { val title = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_title) val message = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_not_responding) @@ -108,26 +224,34 @@ private fun showSingleInstanceAlert(): Boolean { return result == javax.swing.JOptionPane.YES_OPTION } -private fun startShowFileWatcher() { +fun startShowFileWatcher() { if (watcher != null) return + val dir = dataDir.toPath() val ws = try { - dataDir.toPath().fileSystem.newWatchService() + dir.fileSystem.newWatchService() } catch (e: IOException) { Log.w(TAG, "single-instance: WatchService failed: ${e.message}") return } - dataDir.toPath().register(ws, StandardWatchEventKinds.ENTRY_CREATE) + dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE) watcher = ws thread(name = "simplex-single-instance", isDaemon = true) { - while (true) { - val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return@thread } catch (_: InterruptedException) { return@thread } - for (event in key.pollEvents()) { - if ((event.context() as? Path)?.fileName?.toString() == "simplex.show") { - deleteShowFile() - SwingUtilities.invokeLater { showWindow() } - } + watchShowSignals(ws, dir) { signal -> + SwingUtilities.invokeLater { + if (signal.link != null) openDesktopLink(signal.link) else showWindow() } - if (!key.reset()) return@thread } } } + +// dir must already be registered with ws; a signal written before that raised no event, so it is taken first. +internal fun watchShowSignals(ws: WatchService, dir: Path, onSignal: (ShowSignal) -> Unit) { + takeSignal(dir)?.let(onSignal) + while (true) { + val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return } catch (_: InterruptedException) { return } + for (event in key.pollEvents()) { + if ((event.context() as? Path)?.fileName?.toString() == SHOW_FILE) takeSignal(dir)?.let(onSignal) + } + if (!key.reset()) return + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt new file mode 100644 index 0000000000..0859a75225 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt @@ -0,0 +1,63 @@ +package chat.simplex.common + +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.platform.unix.X11 +import java.awt.Toolkit +import java.awt.Window + +// EWMH source indication of a pager, which window managers exempt from focus stealing prevention +private const val NET_ACTIVE_WINDOW_SOURCE_PAGER = 2L +private const val CLIENT_MESSAGE_FORMAT_BITS = 32 + +// AWT's toFront() sends only XRaiseWindow, which wlroots compositors (Sway) drop and focus stealing +// prevention refuses; _NET_ACTIVE_WINDOW is the request X11 and XWayland window managers act on. +internal fun requestX11Activation(window: Window) { + val x11: X11 + val windowId: Long + try { + x11 = X11.INSTANCE + windowId = Native.getWindowID(window) + } catch (e: LinkageError) { + Log.w(TAG, "window activation: native library unavailable: ${e.message}") + return + } catch (e: IllegalStateException) { + Log.w(TAG, "window activation: no native window: ${e.message}") + return + } + // The map request goes out on AWT's connection; flushing it first lets the window manager map the window before activating it. + Toolkit.getDefaultToolkit().sync() + sendActivation(x11, windowId) +} + +internal fun sendActivation(x11: X11, windowId: Long) { + val display = x11.XOpenDisplay(null) + if (display == null) { + Log.w(TAG, "window activation: cannot open the X display") + return + } + try { + val event = activationEvent(X11.Window(windowId), x11.XInternAtom(display, "_NET_ACTIVE_WINDOW", false)) + val mask = NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong()) + x11.XSendEvent(display, x11.XDefaultRootWindow(display), 0, mask, event) + x11.XFlush(display) + } finally { + x11.XCloseDisplay(display) + } +} + +internal fun activationEvent(window: X11.Window, activeWindowAtom: X11.Atom): X11.XEvent { + val event = X11.XEvent() + event.setType(X11.XClientMessageEvent::class.java) + event.xclient.type = X11.ClientMessage + event.xclient.window = window + event.xclient.message_type = activeWindowAtom + event.xclient.format = CLIENT_MESSAGE_FORMAT_BITS + event.xclient.data.setType(Array::class.java) + event.xclient.data.l[0] = NativeLong(NET_ACTIVE_WINDOW_SOURCE_PAGER) + // the request carries no user interaction timestamp + event.xclient.data.l[1] = NativeLong(X11.CurrentTime.toLong()) + return event +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt index ee415ae82b..0a61864294 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt @@ -6,7 +6,7 @@ import chat.simplex.common.simplexWindowState import chat.simplex.common.views.call.CallMediaType import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.sshtools.twoslices.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index d425d81875..167c3f898d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -6,7 +6,7 @@ import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* import java.util.* -import chat.simplex.res.MR +import chat.simplex.res.* import java.io.File actual val appPlatform = AppPlatform.DESKTOP diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt new file mode 100644 index 0000000000..1187316959 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt @@ -0,0 +1,368 @@ +package chat.simplex.common.platform + +import chat.simplex.common.views.chatlist.appLinkScheme +import chat.simplex.common.views.chatlist.connectionLinkScheme +import com.sun.jna.NativeLibrary +import com.sun.jna.WString +import com.sun.jna.platform.win32.Advapi32Util +import com.sun.jna.platform.win32.Win32Exception +import com.sun.jna.platform.win32.WinReg.HKEY +import com.sun.jna.platform.win32.WinReg.HKEY_CURRENT_USER +import com.sun.jna.platform.win32.WinReg.HKEY_LOCAL_MACHINE +import com.sun.jna.ptr.IntByReference +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFileAttributes +import java.nio.file.attribute.PosixFilePermission +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +internal const val APP_LINK_MIME_TYPE = "x-scheme-handler/$appLinkScheme" +internal const val CONNECTION_LINK_MIME_TYPE = "x-scheme-handler/$connectionLinkScheme" +private const val WINDOWS_CLASSES_KEY = "Software\\Classes" +private const val WINDOWS_PROG_ID_PREFIX = "SimpleX." +private const val WINDOWS_CAPABILITIES_KEY = "Software\\chat.simplex.app\\Capabilities" +private const val WINDOWS_REGISTERED_APPLICATIONS_KEY = "Software\\RegisteredApplications" +private const val APP_DESCRIPTION = "Private and secure open-source messenger - no user IDs (not even random numbers)" +private const val WINDOWS_FIRST_ICON_INDEX = 0 +// values from shlwapi.h and shlobj_core.h +private const val ASSOCF_IS_PROTOCOL = 0x1000 +private const val ASSOCSTR_EXECUTABLE = 2 +private const val S_OK = 0 +private const val S_FALSE = 1 +private const val SHCNE_ASSOCCHANGED = 0x08000000 +private const val SHCNF_IDLIST = 0 +// the Win32 name of a key's default value +private const val REGISTRY_DEFAULT_VALUE = "" +internal const val APPIMAGE_ENTRY_NAME = "chat.simplex.app-links.desktop" +private const val APPLICATIONS_DIR = "applications" +// the XDG Base Directory default when XDG_DATA_DIRS is unset +private const val DEFAULT_XDG_DATA_DIRS = "/usr/local/share:/usr/share" +private const val APP_DISPLAY_NAME = "SimpleX Chat" +private const val XDG_MIME = "xdg-mime" +// jpackage names the deb's desktop entry -.desktop +private const val DEB_ENTRY_NAME = "simplex-simplex.desktop" +private const val PROCESS_TIMEOUT_SECONDS = 5L +private const val ROOT_USER = "root" +// the characters the desktop entry spec requires an Exec argument to quote +private const val EXEC_RESERVED_CHARACTERS = " \t\n\"'\\><~|&;$*?#()`" + +internal sealed interface DesktopInstallation { + data object MacBundle : DesktopInstallation + data class WindowsExe(val path: String) : DesktopInstallation + data object Flatpak : DesktopInstallation + data class AppImage(val path: String) : DesktopInstallation + data object LinuxPackage : DesktopInstallation + data object Unpackaged : DesktopInstallation +} + +// Only simplexchat: decides how the badge page ends, so simplex: is registered without being checked here. +@Volatile +private var registered = false + +actual fun appLinkSchemeRegistered(): Boolean = registered + +fun registerLinkSchemes() { + thread(name = "simplex-link-schemes", isDaemon = true) { + registered = registerForThisInstallation() + } +} + +// appPath is jpackage.app-path, set by jpackage's launcher, so only a packaged app has one. +internal fun desktopInstallation(platform: DesktopPlatform, appPath: String?, env: Map): DesktopInstallation { + val appImage = env["APPIMAGE"] + val appDir = env["APPDIR"] + return when { + appPath == null -> DesktopInstallation.Unpackaged + platform.isMac() -> DesktopInstallation.MacBundle + platform.isWindows() -> DesktopInstallation.WindowsExe(appPath) + env["container"] == "flatpak" -> DesktopInstallation.Flatpak + // The AppImage runtime exports APPIMAGE to every child process, so it describes this app only + // when the launcher runs from the AppImage's own mount. + appImage != null && appDir != null && Path.of(appPath).startsWith(realPathOrSelf(appDir)) -> DesktopInstallation.AppImage(appImage) + else -> DesktopInstallation.LinuxPackage + } +} + +// jpackage.app-path is resolved through /proc/self/exe, while APPDIR keeps any symlink in TMPDIR. +private fun realPathOrSelf(path: String): Path = + try { + Path.of(path).toRealPath() + } catch (_: IOException) { + Path.of(path) + } + +private fun registerForThisInstallation(): Boolean = + when (val installation = desktopInstallation(desktopPlatform, System.getProperty("jpackage.app-path"), System.getenv())) { + // The bundle's Info.plist and the Flatpak's exported desktop entry register the schemes. + DesktopInstallation.MacBundle, DesktopInstallation.Flatpak -> true + is DesktopInstallation.WindowsExe -> registerWindowsSchemes(installation.path, SystemWindowsShell) + is DesktopInstallation.AppImage -> + registerAppImageScheme(installation.path, File(unixDataHome, APPLICATIONS_DIR), systemApplicationsDirs(), ::runProcess) + DesktopInstallation.LinuxPackage -> linuxPackageRegistered(::runProcess) + DesktopInstallation.Unpackaged -> false + } + +internal fun windowsOpenCommand(appPath: String): String = "\"$appPath\" \"%1\"" + +internal fun windowsCommandProgram(command: String): String = + if (command.startsWith("\"")) command.substring(1).substringBefore('"') else command.substringBefore(' ') + +internal data class RegistryValue(val key: String, val name: String, val value: String) + +private fun windowsSchemeKey(scheme: String): String = "$WINDOWS_CLASSES_KEY\\$scheme" + +private fun windowsCommandKey(scheme: String): String = "${windowsSchemeKey(scheme)}\\shell\\open\\command" + +private fun windowsProgId(scheme: String): String = "$WINDOWS_PROG_ID_PREFIX$scheme" + +private fun windowsProgIdKey(scheme: String): String = "$WINDOWS_CLASSES_KEY\\${windowsProgId(scheme)}" + +private fun windowsProgIdCommandKey(scheme: String): String = "${windowsProgIdKey(scheme)}\\shell\\open\\command" + +private fun windowsIcon(appPath: String): String = "\"$appPath\",$WINDOWS_FIRST_ICON_INDEX" + +// The scheme key declares the URL scheme, but its command opens links only while no app registered through +// Capabilities claims the scheme: Windows settings offer only the ProgIds that Capabilities name. +internal fun windowsSchemeValues(scheme: String, appPath: String): List { + val schemeKey = windowsSchemeKey(scheme) + return listOf( + RegistryValue(key = schemeKey, name = REGISTRY_DEFAULT_VALUE, value = "URL:$APP_DISPLAY_NAME"), + // without this value Windows does not treat the key as a URL scheme + RegistryValue(key = schemeKey, name = "URL Protocol", value = ""), + RegistryValue(key = "$schemeKey\\DefaultIcon", name = REGISTRY_DEFAULT_VALUE, value = windowsIcon(appPath)), + RegistryValue(key = windowsCommandKey(scheme), name = REGISTRY_DEFAULT_VALUE, value = windowsOpenCommand(appPath)), + RegistryValue(key = "$WINDOWS_CAPABILITIES_KEY\\URLAssociations", name = scheme, value = windowsProgId(scheme)), + ) +} + +// The ProgId is what links resolve to once the user picks SimpleX in settings, so it is repaired even while +// another program holds the scheme key, but another installed copy's ProgId is kept like any other handler. +internal fun windowsProgIdValues(scheme: String, appPath: String): List { + val progIdKey = windowsProgIdKey(scheme) + return listOf( + RegistryValue(key = progIdKey, name = REGISTRY_DEFAULT_VALUE, value = "URL:$APP_DISPLAY_NAME"), + RegistryValue(key = "$progIdKey\\DefaultIcon", name = REGISTRY_DEFAULT_VALUE, value = windowsIcon(appPath)), + RegistryValue(key = windowsProgIdCommandKey(scheme), name = REGISTRY_DEFAULT_VALUE, value = windowsOpenCommand(appPath)), + ) +} + +// The application is listed last, so Windows never lists it before its capabilities are complete. +internal val windowsApplicationValues: List = listOf( + RegistryValue(key = WINDOWS_CAPABILITIES_KEY, name = "ApplicationName", value = APP_DISPLAY_NAME), + RegistryValue(key = WINDOWS_CAPABILITIES_KEY, name = "ApplicationDescription", value = APP_DESCRIPTION), + RegistryValue(key = WINDOWS_REGISTERED_APPLICATIONS_KEY, name = APP_DISPLAY_NAME, value = WINDOWS_CAPABILITIES_KEY), +) + +internal interface WindowsShell { + fun readUser(key: String, name: String): String? + fun readMachine(key: String, name: String): String? + fun writeUser(value: RegistryValue) + // the program the shell starts for a link, after any choice made in Windows settings + fun linkHandler(scheme: String): String? + fun notifyAssociationsChanged() +} + +internal fun registerWindowsSchemes(appPath: String, shell: WindowsShell): Boolean { + var changed = false + val tracked = object : WindowsShell by shell { + override fun writeUser(value: RegistryValue) { + shell.writeUser(value) + changed = true + } + } + val associated = listOf(appLinkScheme, connectionLinkScheme).filter { registerWindowsScheme(it, appPath, tracked) } + if (associated.isNotEmpty()) writeMissing(windowsApplicationValues, tracked, "the application") + // The notification also clears Explorer's icon cache, so it is sent only after a change. + if (changed) shell.notifyAssociationsChanged() + return shell.linkHandler(appLinkScheme)?.let { isThisProgram(it, appPath) } == true +} + +private fun registerWindowsScheme(scheme: String, appPath: String, shell: WindowsShell): Boolean = + !heldByAnotherProgram(windowsProgIdCommandKey(scheme), appPath, shell) && + writeMissing(windowsProgIdValues(scheme, appPath), shell, "the $scheme: ProgId") && + !heldByAnotherProgram(windowsCommandKey(scheme), appPath, shell) && + writeMissing(windowsSchemeValues(scheme, appPath), shell, "the $scheme: scheme") + +private fun isThisProgram(program: String, appPath: String): Boolean = + File(program).absolutePath.equals(File(appPath).absolutePath, ignoreCase = true) + +// A per-user key hides the machine-wide one, so the machine-wide command counts only without it. +private fun heldByAnotherProgram(commandKey: String, appPath: String, shell: WindowsShell): Boolean = + try { + val current = shell.readUser(commandKey, REGISTRY_DEFAULT_VALUE) ?: shell.readMachine(commandKey, REGISTRY_DEFAULT_VALUE) + val program = current?.let(::windowsCommandProgram) + program != null && !isThisProgram(program, appPath) && File(program).isFile + } catch (e: Win32Exception) { + Log.w(TAG, "link scheme: cannot read the handler under $commandKey: ${e.message}") + true + } + +private fun writeMissing(values: List, shell: WindowsShell, what: String): Boolean { + fun stored() = values.all { shell.readUser(it.key, it.name) == it.value } + return try { + if (!stored()) values.forEach(shell::writeUser) + stored() + } catch (e: Win32Exception) { + Log.w(TAG, "link scheme: cannot register $what in the registry: ${e.message}") + false + } +} + +private object SystemWindowsShell : WindowsShell { + override fun readUser(key: String, name: String): String? = registryString(HKEY_CURRENT_USER, key, name) + + override fun readMachine(key: String, name: String): String? = registryString(HKEY_LOCAL_MACHINE, key, name) + + override fun writeUser(value: RegistryValue) { + Advapi32Util.registryCreateKey(HKEY_CURRENT_USER, value.key) + Advapi32Util.registrySetStringValue(HKEY_CURRENT_USER, value.key, value.name, value.value) + } + + // jna-platform 5.14 binds neither AssocQueryStringW nor SHChangeNotify. + override fun linkHandler(scheme: String): String? = + try { + val assocQueryString = NativeLibrary.getInstance("shlwapi").getFunction("AssocQueryStringW") + val length = IntByReference(0) + // with no buffer the call reports the length it needs + val sized = assocQueryString.invokeInt(arrayOf(ASSOCF_IS_PROTOCOL, ASSOCSTR_EXECUTABLE, WString(scheme), null, null, length)) + if (sized != S_FALSE) { + null + } else { + val out = CharArray(length.value) + val result = assocQueryString.invokeInt(arrayOf(ASSOCF_IS_PROTOCOL, ASSOCSTR_EXECUTABLE, WString(scheme), null, out, length)) + if (result == S_OK) String(out).substringBefore('\u0000') else null + } + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, "link scheme: cannot query the $scheme: handler: ${e.message}") + null + } + + override fun notifyAssociationsChanged() { + try { + NativeLibrary.getInstance("shell32").getFunction("SHChangeNotify").invokeVoid(arrayOf(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, null, null)) + } catch (e: UnsatisfiedLinkError) { + Log.w(TAG, "link scheme: cannot announce changed associations: ${e.message}") + } + } +} + +// registryGetStringValue throws a RuntimeException for a value of another type, which another program could write. +private fun registryString(hive: HKEY, key: String, name: String): String? = + if (Advapi32Util.registryValueExists(hive, key, name)) { + Advapi32Util.registryGetValue(hive, key, name) as? String + } else { + null + } + +// Per the desktop entry spec, quoting escapes " ` $ \ and the string escape then doubles every backslash. +// xdg-open 1.1.3 cannot run a quoted program, so quotes are added only when required. +internal fun desktopExecArgument(arg: String): String { + if (arg.none { it in EXEC_RESERVED_CHARACTERS }) return arg + val quoted = arg.replace("\\", "\\\\").replace("\"", "\\\"").replace("`", "\\`").replace("$", "\\$") + return "\"" + quoted.replace("\\", "\\\\") + "\"" +} + +internal fun appImageDesktopEntry(appImagePath: String): String? { + // A line break would end the Exec value and start a key of the path's choosing. + // GLib looks the program up before it expands %%, so no escaping lets it find a path containing %. + if (appImagePath.any { it == '\n' || it == '\r' || it == '%' }) return null + return """ + |[Desktop Entry] + |Type=Application + |Name=$APP_DISPLAY_NAME + |NoDisplay=true + |Exec=${desktopExecArgument(appImagePath)} %u + |MimeType=$APP_LINK_MIME_TYPE;$CONNECTION_LINK_MIME_TYPE; + |""".trimMargin() +} + +// The entry runs this file for every link, so neither it nor any directory above it may be replaceable by another user, +// including the user's own directory inside /tmp, which can be cleaned away and recreated by anyone. +internal fun onlyOwnerCanReplace(path: Path, user: String): Boolean = + try { + generateSequence(path) { it.parent }.all { onlyOwnerCanWrite(Files.readAttributes(it, PosixFileAttributes::class.java), user) } + } catch (_: IOException) { + false + } + +// A group named after the user is a user private group, which umask 002 systems make group-writable. +internal fun onlyOwnerCanWrite(attributes: PosixFileAttributes, user: String): Boolean { + val permissions = attributes.permissions() + return attributes.owner().name in setOf(user, ROOT_USER) && + PosixFilePermission.OTHERS_WRITE !in permissions && + (PosixFilePermission.GROUP_WRITE !in permissions || attributes.group().name == user) +} + +internal fun registerAppImageScheme(appImagePath: String, applicationsDir: File, systemApplicationsDirs: List, run: (List) -> String?): Boolean { + if (!onlyOwnerCanReplace(Path.of(appImagePath), System.getProperty("user.name"))) { + Log.w(TAG, "link scheme: not registered, as other users may replace $appImagePath") + return false + } + val content = appImageDesktopEntry(appImagePath) ?: return false + val entry = File(applicationsDir, APPIMAGE_ENTRY_NAME) + try { + if (!entry.isFile || entry.readText() != content) { + applicationsDir.mkdirs() + entry.writeText(content) + run(listOf("update-desktop-database", applicationsDir.path)) + } + } catch (e: IOException) { + Log.w(TAG, "link scheme: cannot write desktop entry: ${e.message}") + return false + } + val entryDirs = listOf(applicationsDir) + systemApplicationsDirs + val registered = makeAppImageEntryDefault(APP_LINK_MIME_TYPE, entryDirs, run) + makeAppImageEntryDefault(CONNECTION_LINK_MIME_TYPE, entryDirs, run) + return registered +} + +private fun makeAppImageEntryDefault(mimeType: String, entryDirs: List, run: (List) -> String?): Boolean { + val current = xdgDefaultSchemeHandler(mimeType, run) + if (current == APPIMAGE_ENTRY_NAME) return true + // another installed handler keeps the default; only a missing one is replaced + if (current != null && entryDirs.any { File(it, current).isFile }) return false + run(listOf(XDG_MIME, "default", APPIMAGE_ENTRY_NAME, mimeType)) + return xdgDefaultSchemeHandler(mimeType, run) == APPIMAGE_ENTRY_NAME +} + +private fun xdgDefaultSchemeHandler(mimeType: String, run: (List) -> String?): String? = + run(listOf(XDG_MIME, "query", "default", mimeType))?.trim()?.takeIf { it.isNotEmpty() } + +private fun systemApplicationsDirs(): List = + (System.getenv("XDG_DATA_DIRS")?.takeIf { it.isNotEmpty() } ?: DEFAULT_XDG_DATA_DIRS).split(File.pathSeparator).map { File(it, APPLICATIONS_DIR) } + +internal fun linuxPackageRegistered(run: (List) -> String?): Boolean = + xdgDefaultSchemeHandler(APP_LINK_MIME_TYPE, run) == DEB_ENTRY_NAME + +internal fun runProcess(command: List): String? { + val process = try { + ProcessBuilder(command).redirectError(ProcessBuilder.Redirect.DISCARD).start() + } catch (e: IOException) { + Log.w(TAG, "link scheme: cannot run ${command.first()}: ${e.message}") + return null + } + return try { + process.outputStream.close() + if (!process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + Log.w(TAG, "link scheme: ${command.first()} timed out") + null + } else if (process.exitValue() == 0) { + process.inputStream.bufferedReader().readText() + } else { + null + } + } catch (e: IOException) { + Log.w(TAG, "link scheme: ${command.first()} failed: ${e.message}") + null + } catch (_: InterruptedException) { + process.destroyForcibly() + Thread.currentThread().interrupt() + null + } finally { + process.inputStream.close() + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt index 870fde945b..b83f807667 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt @@ -12,4 +12,6 @@ actual val cryptor: CryptorInterface = object : CryptorInterface { override fun deleteKey(alias: String) { // LALAL } + + override fun keyStorage(alias: String): String? = null } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 58260181cf..b779954efc 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.* import chat.simplex.common.* import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR +import chat.simplex.res.* import java.awt.Desktop import java.io.* import java.net.URI diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index 0f830e7b60..925698154e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -5,7 +5,8 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import boofcv.io.image.ConvertBufferedImage import boofcv.struct.image.GrayU8 -import chat.simplex.res.MR +import chat.simplex.common.views.helpers.decodeBoundedBufferedImage +import chat.simplex.res.* import org.jetbrains.skia.Image import java.awt.RenderingHints import java.awt.geom.AffineTransform @@ -54,6 +55,14 @@ actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { } } +actual fun decodeBase64Image(base64: String, width: Int): ImageBitmap? = + try { + decodeBoundedBufferedImage(Base64.getMimeDecoder().decode(base64), width).toComposeImageBitmap() + } catch (e: Throwable) { + Log.e(TAG, "decodeBase64Image error: $e") + null + } + actual fun resizeImageToStrSize(image: ImageBitmap, maxDataSize: Long): String { var img = image var str = compressImageStr(img) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt index b090e301d5..894f31a395 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.* +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.draganddrop.* import androidx.compose.ui.draganddrop.DragData import androidx.compose.ui.input.pointer.* @@ -85,3 +86,5 @@ actual fun Modifier.desktopOnHovered(action: (Boolean) -> Unit): Modifier = this then Modifier .onPointerEvent(PointerEventType.Enter) { action(true) } .onPointerEvent(PointerEventType.Exit) { action(false) } + +actual fun newPasswordContentType(): ContentType = ContentType.NewPassword diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt index 7ea41d3593..8dd4ba96b6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt @@ -5,8 +5,10 @@ import java.util.* private val home = System.getProperty("user.home") private val unixConfigPath = (System.getenv("XDG_CONFIG_HOME") ?: "$home/.config") + "/simplex" -private val unixDataPath = (System.getenv("XDG_DATA_HOME") ?: "$home/.local/share") + "/simplex" -val desktopPlatform = detectDesktopPlatform() +internal val unixDataHome = System.getenv("XDG_DATA_HOME") ?: "$home/.local/share" +private val unixDataPath = "$unixDataHome/simplex" +// DesktopPlatform's constants read this file's paths, so an eager value is null when the enum initializes first. +val desktopPlatform by lazy { detectDesktopPlatform() } enum class DesktopPlatform(val libExtension: String, val configPath: String, val dataPath: String, val githubAssetName: String) { LINUX_X86_64("so", unixConfigPath, unixDataPath, "simplex-desktop-x86_64.AppImage"), diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index 557dabd2e4..3e048a93d5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay import java.awt.Image diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index d59677b726..e5542d5da1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -3,7 +3,7 @@ package chat.simplex.common.platform import androidx.compose.runtime.* import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.player.base.MediaPlayer @@ -313,7 +313,9 @@ actual object CallSoundsPlayer: CallSoundsPlayerInterface { override fun startConnectingCallSound(scope: CoroutineScope) { // Taken from https://github.com/TelegramOrg/Telegram-Android // https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE - start("/media/connecting_call.mp3", 3000, scope) + // the delay is the length of the track exactly, so it repeats with no delay, which already happens on ios and android + // Android (isLooping) and iOS (which sleeps for the track duration) + start("/media/connecting_call.mp3", 3536, scope) } override fun startInCallSound(scope: CoroutineScope) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt index 4e7594f998..062f26cfd7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.simplexWindowState import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import com.jthemedetecor.OsThemeDetector import com.russhwolf.settings.* import dev.icerock.moko.resources.ImageResource diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt index a10f675085..435a8891b7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Share.desktop.kt @@ -8,7 +8,7 @@ import chat.simplex.common.views.helpers.* import java.io.File import java.net.URI import java.net.URLEncoder -import chat.simplex.res.MR +import chat.simplex.res.* import java.awt.Desktop actual fun UriHandler.sendEmail(subject: String, body: CharSequence) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index d9375f5d79..52f11de64d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface import uk.co.caprica.vlcj.media.Media diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt index 082036558c..7639c567d4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Type.desktop.kt @@ -3,26 +3,26 @@ package chat.simplex.common.ui.theme import androidx.compose.ui.text.font.* import androidx.compose.ui.text.platform.Font import chat.simplex.common.platform.desktopPlatform -import chat.simplex.res.MR +import chat.simplex.res.* actual val Inter: FontFamily = FontFamily( - Font(MR.fonts.Inter.regular.file), - Font(MR.fonts.Inter.italic.file, style = FontStyle.Italic), - Font(MR.fonts.Inter.bold.file, FontWeight.Bold), - Font(MR.fonts.Inter.semibold.file, FontWeight.SemiBold), - Font(MR.fonts.Inter.medium.file, FontWeight.Medium), - Font(MR.fonts.Inter.light.file, FontWeight.Light) + Font(MR.fonts.inter_regular.file), + Font(MR.fonts.inter_italic.file, style = FontStyle.Italic), + Font(MR.fonts.inter_bold.file, FontWeight.Bold), + Font(MR.fonts.inter_semibold.file, FontWeight.SemiBold), + Font(MR.fonts.inter_medium.file, FontWeight.Medium), + Font(MR.fonts.inter_light.file, FontWeight.Light) ) actual val EmojiFont: FontFamily = if (desktopPlatform.isMac()) { FontFamily.Default } else { FontFamily( - Font(MR.fonts.NotoColorEmoji.regular.file), - Font(MR.fonts.NotoColorEmoji.regular.file, style = FontStyle.Italic), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Bold), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.SemiBold), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Medium), - Font(MR.fonts.NotoColorEmoji.regular.file, FontWeight.Light) + Font(MRdesktopMain.fonts.notocoloremoji_regular.file), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, style = FontStyle.Italic), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.Bold), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.SemiBold), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.Medium), + Font(MRdesktopMain.fonts.notocoloremoji_regular.file, FontWeight.Light) ) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index bd8e0d771a..616f46dab6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -6,8 +6,9 @@ import androidx.compose.ui.platform.LocalUriHandler import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString @@ -55,13 +56,24 @@ actual fun ActiveCallView() { CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { - chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) + chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.session, r.iceCandidates) } is WCallResponse.Connection -> - try { + // the call is restored and NOT sent to the core + // Makes sure the core DOES NOT record the duration wrong if a reconnect happens + if (r.state.connectionState == "reconnecting") { + if (call.callState != CallState.Reconnecting) { + CallSoundsPlayer.startConnectingCallSound(scope) + } + chatModel.activeCall.value = call.copy(callState = CallState.Reconnecting) + } else try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) + if (call.callState == CallState.Reconnecting) { + CallSoundsPlayer.stop() + } + // connectedAt is only set ONCE, this way call duration is NOT reset when the call is restored from reconnecting + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = call.connectedAt ?: Clock.System.now()) } withBGApi { chatModel.controller.apiCallStatus(callRh, call.contact, callStatus) } } catch (e: Throwable) { @@ -194,21 +206,20 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( } } LaunchedEffect(Unit) { - snapshotFlow { callCommand.firstOrNull() } - .distinctUntilChanged() - .filterNotNull() - .collect { - while (connections.isEmpty()) { - delay(100) - } - while (callCommand.isNotEmpty()) { - val cmd = callCommand.removeFirstOrNull() - Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}") - if (cmd != null) { - processCommand(cmd) - } + while (isActive) { + // waiting for the first command would miss a command equal to the previous one + snapshotFlow { callCommand.isNotEmpty() }.first { it } + while (connections.isEmpty()) { + delay(100) + } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirstOrNull() + Log.d(TAG, "WebRTCController LaunchedEffect executing ${cmd?.javaClass?.simpleName}") + if (cmd != null) { + processCommand(cmd) } } + } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt new file mode 100644 index 0000000000..65a3bb8c5a --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt @@ -0,0 +1,248 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.* +import androidx.compose.foundation.shape.CircleShape +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.draw.clip +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.* +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.* +import androidx.compose.ui.window.* +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.ui.theme.EmojiFont +import chat.simplex.common.views.helpers.KeyChangeEffect +import chat.simplex.common.views.helpers.SearchTextField +import chat.simplex.res.* +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.launch + +private const val EMOJI_DATA_RESOURCE = "/emoji/emoji.txt" +private const val EMOJI_GROUP_PREFIX = "@" +private val EMOJI_POPUP_WIDTH = 360.dp +private val EMOJI_POPUP_HEIGHT = 420.dp +private val EMOJI_CELL_SIZE = 38.dp +private val EMOJI_SIZE = 26.dp +private val EMOJI_TAB_SIZE = 32.dp +private val EMOJI_TAB_ICON_SIZE = 20.dp +// the buttons left of the message field overlap by 8 dp, this keeps the emoji button a little apart +private val EMOJI_BUTTON_START_PADDING = 4.dp + +enum class EmojiCategory(val unicodeGroup: String, val icon: String, val title: StringResource) { + Smileys("Smileys & Emotion", "😀", MR.strings.emoji_category_smileys), + People("People & Body", "👋", MR.strings.emoji_category_people), + Animals("Animals & Nature", "🐻", MR.strings.emoji_category_animals), + Food("Food & Drink", "🍔", MR.strings.emoji_category_food), + Travel("Travel & Places", "🚗", MR.strings.emoji_category_travel), + Activities("Activities", "⚽", MR.strings.emoji_category_activities), + Objects("Objects", "💡", MR.strings.emoji_category_objects), + Symbols("Symbols", "🔣", MR.strings.emoji_category_symbols), + Flags("Flags", "🏁", MR.strings.emoji_category_flags), +} + +data class EmojiData(val emoji: String, val name: String) + +data class EmojiSection(val category: EmojiCategory, val emojis: List) + +fun parseEmojiData(lines: Sequence): List { + val sections = mutableListOf() + var emojis = mutableListOf() + for (line in lines) { + if (line.isEmpty()) continue + if (line.startsWith(EMOJI_GROUP_PREFIX)) { + val group = line.removePrefix(EMOJI_GROUP_PREFIX) + val category = EmojiCategory.entries.firstOrNull { it.unicodeGroup == group } + ?: throw IllegalArgumentException("unknown emoji group: $group") + emojis = mutableListOf() + sections.add(EmojiSection(category, emojis)) + } else { + val (emoji, name) = line.split("\t", limit = 2) + emojis.add(EmojiData(emoji, name)) + } + } + return sections +} + +val emojiSections: List by lazy { + val stream = EmojiCategory::class.java.getResourceAsStream(EMOJI_DATA_RESOURCE) + ?: throw IllegalStateException("missing resource $EMOJI_DATA_RESOURCE") + stream.bufferedReader().useLines(::parseEmojiData) +} + +fun searchEmoji(sections: List, query: String): List { + val q = query.trim() + return sections.flatMap { s -> s.emojis.filter { it.name.contains(q, ignoreCase = true) } } +} + +@Composable +actual fun ComposeEmojiButton(pickerShown: MutableState, enabled: Boolean, onEmoji: (String) -> Unit) { + // the popup opens above the button, so in a short window it must not be taller than the space there + var spaceAbove by remember { mutableStateOf(EMOJI_POPUP_HEIGHT) } + val density = LocalDensity.current + Box(Modifier.padding(start = EMOJI_BUTTON_START_PADDING).onGloballyPositioned { spaceAbove = with(density) { it.positionInWindow().y.toDp() } }) { + EmojiIconButton(painterResource(MR.images.ic_add_reaction), stringResource(MR.strings.icon_descr_emoji), enabled) { + pickerShown.value = !pickerShown.value + } + if (pickerShown.value) { + Popup( + popupPositionProvider = AboveAnchorPositionProvider, + onDismissRequest = { pickerShown.value = false }, + properties = PopupProperties(focusable = true), + // closing on KeyUp, as the window does, so that the same key release does not also close the top modal + onKeyEvent = { + if (it.key == Key.Escape) { + if (it.type == KeyEventType.KeyUp) pickerShown.value = false + true + } else false + } + ) { + EmojiPicker(height = minOf(EMOJI_POPUP_HEIGHT, spaceAbove), onEmoji = onEmoji) + } + } + } +} + +private object AboveAnchorPositionProvider : PopupPositionProvider { + override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset { + val x = if (layoutDirection == LayoutDirection.Ltr) anchorBounds.left else anchorBounds.right - popupContentSize.width + return IntOffset( + x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)), + (anchorBounds.top - popupContentSize.height).coerceAtLeast(0) + ) + } +} + +@Composable +private fun EmojiPicker(height: Dp, onEmoji: (String) -> Unit) { + val sections = remember { emojiSections } + val query = remember { mutableStateOf("") } + val found = remember { derivedStateOf { if (query.value.isBlank()) null else searchEmoji(sections, query.value) } } + val gridState = rememberLazyGridState() + KeyChangeEffect(found.value) { gridState.scrollToItem(0) } + val headerIndexes = remember(sections) { + var index = 0 + sections.map { s -> index.also { index += s.emojis.size + 1 } } + } + Surface( + Modifier.size(EMOJI_POPUP_WIDTH, height), + shape = RoundedCornerShape(12.dp), + elevation = 8.dp, + border = BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.2f)) + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(MR.images.ic_search), + contentDescription = null, + Modifier.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING_HALF).size(22.dp), + tint = MaterialTheme.colors.secondary + ) + SearchTextField(Modifier.weight(1f), alwaysVisible = false) { query.value = it } + } + if (found.value == null) { + EmojiCategoryTabs(sections, headerIndexes, gridState) + } + Divider() + LazyVerticalGrid( + columns = GridCells.Adaptive(EMOJI_CELL_SIZE), + state = gridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(6.dp) + ) { + val foundEmojis = found.value + if (foundEmojis == null) { + sections.forEach { s -> + sectionHeader(s.category.title) + items(s.emojis) { EmojiCell(it.emoji, onEmoji) } + } + } else if (foundEmojis.isEmpty()) { + sectionHeader(MR.strings.no_emoji_found) + } else { + items(foundEmojis) { EmojiCell(it.emoji, onEmoji) } + } + } + } + } +} + +@Composable +private fun EmojiCategoryTabs(sections: List, headerIndexes: List, gridState: LazyGridState) { + val scope = rememberCoroutineScope() + val selected = remember { + derivedStateOf { headerIndexes.indexOfLast { it <= gridState.firstVisibleItemIndex } } + } + Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween) { + sections.forEachIndexed { i, s -> + EmojiTab(selected = selected.value == i, onClick = { scope.launch { gridState.scrollToItem(headerIndexes[i]) } }) { + Text(s.category.icon, fontFamily = EmojiFont, fontSize = with(LocalDensity.current) { EMOJI_TAB_ICON_SIZE.toSp() }) + } + } + } +} + +@Composable +private fun EmojiTab(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) { + Box( + Modifier + .size(EMOJI_TAB_SIZE) + .clip(RoundedCornerShape(8.dp)) + .background(if (selected) MaterialTheme.colors.secondary.copy(alpha = 0.15f) else Color.Transparent) + .focusProperties { canFocus = false } + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + content() + } +} + +private fun LazyGridScope.sectionHeader(title: StringResource) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + stringResource(title), + Modifier.padding(start = 4.dp, top = 8.dp, bottom = 4.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary + ) + } +} + +@Composable +private fun EmojiCell(emoji: String, onEmoji: (String) -> Unit) { + Box( + Modifier + .size(EMOJI_CELL_SIZE) + .clip(RoundedCornerShape(8.dp)) + // not focusable, so that the search field keeps focus and typing after a click goes to search + .focusProperties { canFocus = false } + .clickable { onEmoji(emoji) }, + contentAlignment = Alignment.Center + ) { + Text(emoji, fontFamily = EmojiFont, fontSize = with(LocalDensity.current) { EMOJI_SIZE.toSp() }, maxLines = 1) + } +} + +@Composable +private fun EmojiIconButton(icon: Painter, contentDescription: String, enabled: Boolean, onClick: () -> Unit) { + IconButton(onClick, enabled = enabled) { + Icon( + icon, + contentDescription = contentDescription, + tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + ) + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 98ffa7c8a4..dd76e86c81 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -23,7 +23,7 @@ actual fun SimpleAndAnimatedImageView( // still image. A full screen modal is shown beside the chat rather than in place of it, so this item keeps // composing under one and would otherwise decode where nobody can see it. val frame = if (smallView) imageBitmap - else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() } + else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() || ModalManager.center.hasModalsOpen() } ImageView(BitmapPainter(frame)) { if (getLoadedFilePath(file) != null) { ModalManager.fullscreen.showCustomModal(animated = false) { close -> diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index e6f782d816..997100246c 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import java.io.File diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt index 05ed7002e5..64dc8d61a6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.desktop.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import chat.simplex.common.model.MREmojiChar import chat.simplex.common.platform.desktopPlatform -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index 9fb4afbf7d..c1d4a40dae 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.getBitmapFromByteArray -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt index 9fd65ec995..250d7ed3d9 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -17,7 +17,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.Call import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt index 75a76014a9..dba5ddb9e6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt @@ -18,7 +18,7 @@ import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.views.chat.item.isHeartEmoji import chat.simplex.common.views.chat.item.isShortEmoji import chat.simplex.common.views.helpers.toDp -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt index 4535857696..2d5f58a545 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.WarningOrange import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -59,6 +59,7 @@ actual fun DatabaseEncryptionFooter( useKeychain: MutableState, chatDbEncrypted: Boolean?, storedKey: MutableState, + keyStorage: String?, initialRandomDBPassphrase: MutableState, migration: Boolean, ) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt index e857ef91b1..306562e6ad 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -15,7 +15,7 @@ import chat.simplex.common.model.json import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.WarningOrange import chat.simplex.common.views.onboarding.ReadMoreButton -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt index d125f94e55..4ff8c552c4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import chat.simplex.common.views.newchat.ActionButton -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt index 79aaf5c727..4f26f1d5fe 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.DialogParams import chat.simplex.common.platform.desktopPlatform -import chat.simplex.res.MR +import chat.simplex.res.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.awt.FileDialog diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt index 59865dd66f..d4d9415501 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/GetImageView.desktop.kt @@ -9,7 +9,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.platform.rememberFileChooserLauncher -import chat.simplex.res.MR +import chat.simplex.res.* import chat.simplex.common.views.newchat.ActionButton import java.net.URI diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt index 1b9582b7d2..30e1020d48 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt @@ -9,7 +9,7 @@ import chat.simplex.common.model.SharedPreference import chat.simplex.common.model.User import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt index 6b36a3b1b2..7a48f37271 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt @@ -26,7 +26,7 @@ import chat.simplex.common.trayIsAvailable import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay import java.util.Locale diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt index a51fd20c5b..325a475218 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.desktop.kt @@ -3,7 +3,7 @@ package chat.simplex.common.views.usersettings import SectionView import androidx.compose.runtime.Composable import chat.simplex.common.model.ChatModel -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource @Composable diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt index 174ad63c7a..8ea4a3c5e7 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt @@ -6,7 +6,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR +import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fonts/NotoColorEmoji-Regular.ttf b/apps/multiplatform/common/src/desktopMain/moko-resources/fonts/NotoColorEmoji-Regular.ttf similarity index 100% rename from apps/multiplatform/common/src/commonMain/resources/MR/fonts/NotoColorEmoji-Regular.ttf rename to apps/multiplatform/common/src/desktopMain/moko-resources/fonts/NotoColorEmoji-Regular.ttf diff --git a/apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt b/apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt new file mode 100644 index 0000000000..2a25c14c9f --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/resources/emoji/emoji.txt @@ -0,0 +1,1879 @@ +@Smileys & Emotion +😀 grinning face +😃 grinning face with big eyes +😄 grinning face with smiling eyes +😁 beaming face with smiling eyes +😆 grinning squinting face +😅 grinning face with sweat +🤣 rolling on the floor laughing +😂 face with tears of joy +🙂 slightly smiling face +🙃 upside-down face +🫠 melting face +😉 winking face +😊 smiling face with smiling eyes +😇 smiling face with halo +🥰 smiling face with hearts +😍 smiling face with heart-eyes +🤩 star-struck +😘 face blowing a kiss +😗 kissing face +☺️ smiling face +😚 kissing face with closed eyes +😙 kissing face with smiling eyes +🥲 smiling face with tear +😋 face savoring food +😛 face with tongue +😜 winking face with tongue +🤪 zany face +😝 squinting face with tongue +🤑 money-mouth face +🤗 smiling face with open hands +🤭 face with hand over mouth +🫢 face with open eyes and hand over mouth +🫣 face with peeking eye +🤫 shushing face +🤔 thinking face +🫡 saluting face +🤐 zipper-mouth face +🤨 face with raised eyebrow +😐 neutral face +😑 expressionless face +😶 face without mouth +🫥 dotted line face +😶‍🌫️ face in clouds +😏 smirking face +😒 unamused face +🙄 face with rolling eyes +😬 grimacing face +😮‍💨 face exhaling +🤥 lying face +🫨 shaking face +😌 relieved face +😔 pensive face +😪 sleepy face +🤤 drooling face +😴 sleeping face +😷 face with medical mask +🤒 face with thermometer +🤕 face with head-bandage +🤢 nauseated face +🤮 face vomiting +🤧 sneezing face +🥵 hot face +🥶 cold face +🥴 woozy face +😵 face with crossed-out eyes +😵‍💫 face with spiral eyes +🤯 exploding head +🤠 cowboy hat face +🥳 partying face +🥸 disguised face +😎 smiling face with sunglasses +🤓 nerd face +🧐 face with monocle +😕 confused face +🫤 face with diagonal mouth +😟 worried face +🙁 slightly frowning face +☹️ frowning face +😮 face with open mouth +😯 hushed face +😲 astonished face +😳 flushed face +🥺 pleading face +🥹 face holding back tears +😦 frowning face with open mouth +😧 anguished face +😨 fearful face +😰 anxious face with sweat +😥 sad but relieved face +😢 crying face +😭 loudly crying face +😱 face screaming in fear +😖 confounded face +😣 persevering face +😞 disappointed face +😓 downcast face with sweat +😩 weary face +😫 tired face +🥱 yawning face +😤 face with steam from nose +😡 enraged face +😠 angry face +🤬 face with symbols on mouth +😈 smiling face with horns +👿 angry face with horns +💀 skull +☠️ skull and crossbones +💩 pile of poo +🤡 clown face +👹 ogre +👺 goblin +👻 ghost +👽 alien +👾 alien monster +🤖 robot +😺 grinning cat +😸 grinning cat with smiling eyes +😹 cat with tears of joy +😻 smiling cat with heart-eyes +😼 cat with wry smile +😽 kissing cat +🙀 weary cat +😿 crying cat +😾 pouting cat +🙈 see-no-evil monkey +🙉 hear-no-evil monkey +🙊 speak-no-evil monkey +💌 love letter +💘 heart with arrow +💝 heart with ribbon +💖 sparkling heart +💗 growing heart +💓 beating heart +💞 revolving hearts +💕 two hearts +💟 heart decoration +❣️ heart exclamation +💔 broken heart +❤️‍🔥 heart on fire +❤️‍🩹 mending heart +❤️ red heart +🩷 pink heart +🧡 orange heart +💛 yellow heart +💚 green heart +💙 blue heart +🩵 light blue heart +💜 purple heart +🤎 brown heart +🖤 black heart +🩶 grey heart +🤍 white heart +💋 kiss mark +💯 hundred points +💢 anger symbol +💥 collision +💫 dizzy +💦 sweat droplets +💨 dashing away +🕳️ hole +💬 speech balloon +👁️‍🗨️ eye in speech bubble +🗨️ left speech bubble +🗯️ right anger bubble +💭 thought balloon +💤 ZZZ +@People & Body +👋 waving hand +🤚 raised back of hand +🖐️ hand with fingers splayed +✋ raised hand +🖖 vulcan salute +🫱 rightwards hand +🫲 leftwards hand +🫳 palm down hand +🫴 palm up hand +🫷 leftwards pushing hand +🫸 rightwards pushing hand +👌 OK hand +🤌 pinched fingers +🤏 pinching hand +✌️ victory hand +🤞 crossed fingers +🫰 hand with index finger and thumb crossed +🤟 love-you gesture +🤘 sign of the horns +🤙 call me hand +👈 backhand index pointing left +👉 backhand index pointing right +👆 backhand index pointing up +🖕 middle finger +👇 backhand index pointing down +☝️ index pointing up +🫵 index pointing at the viewer +👍 thumbs up +👎 thumbs down +✊ raised fist +👊 oncoming fist +🤛 left-facing fist +🤜 right-facing fist +👏 clapping hands +🙌 raising hands +🫶 heart hands +👐 open hands +🤲 palms up together +🤝 handshake +🙏 folded hands +✍️ writing hand +💅 nail polish +🤳 selfie +💪 flexed biceps +🦾 mechanical arm +🦿 mechanical leg +🦵 leg +🦶 foot +👂 ear +🦻 ear with hearing aid +👃 nose +🧠 brain +🫀 anatomical heart +🫁 lungs +🦷 tooth +🦴 bone +👀 eyes +👁️ eye +👅 tongue +👄 mouth +🫦 biting lip +👶 baby +🧒 child +👦 boy +👧 girl +🧑 person +👱 person: blond hair +👨 man +🧔 person: beard +🧔‍♂️ man: beard +🧔‍♀️ woman: beard +👨‍🦰 man: red hair +👨‍🦱 man: curly hair +👨‍🦳 man: white hair +👨‍🦲 man: bald +👩 woman +👩‍🦰 woman: red hair +🧑‍🦰 person: red hair +👩‍🦱 woman: curly hair +🧑‍🦱 person: curly hair +👩‍🦳 woman: white hair +🧑‍🦳 person: white hair +👩‍🦲 woman: bald +🧑‍🦲 person: bald +👱‍♀️ woman: blond hair +👱‍♂️ man: blond hair +🧓 older person +👴 old man +👵 old woman +🙍 person frowning +🙍‍♂️ man frowning +🙍‍♀️ woman frowning +🙎 person pouting +🙎‍♂️ man pouting +🙎‍♀️ woman pouting +🙅 person gesturing NO +🙅‍♂️ man gesturing NO +🙅‍♀️ woman gesturing NO +🙆 person gesturing OK +🙆‍♂️ man gesturing OK +🙆‍♀️ woman gesturing OK +💁 person tipping hand +💁‍♂️ man tipping hand +💁‍♀️ woman tipping hand +🙋 person raising hand +🙋‍♂️ man raising hand +🙋‍♀️ woman raising hand +🧏 deaf person +🧏‍♂️ deaf man +🧏‍♀️ deaf woman +🙇 person bowing +🙇‍♂️ man bowing +🙇‍♀️ woman bowing +🤦 person facepalming +🤦‍♂️ man facepalming +🤦‍♀️ woman facepalming +🤷 person shrugging +🤷‍♂️ man shrugging +🤷‍♀️ woman shrugging +🧑‍⚕️ health worker +👨‍⚕️ man health worker +👩‍⚕️ woman health worker +🧑‍🎓 student +👨‍🎓 man student +👩‍🎓 woman student +🧑‍🏫 teacher +👨‍🏫 man teacher +👩‍🏫 woman teacher +🧑‍⚖️ judge +👨‍⚖️ man judge +👩‍⚖️ woman judge +🧑‍🌾 farmer +👨‍🌾 man farmer +👩‍🌾 woman farmer +🧑‍🍳 cook +👨‍🍳 man cook +👩‍🍳 woman cook +🧑‍🔧 mechanic +👨‍🔧 man mechanic +👩‍🔧 woman mechanic +🧑‍🏭 factory worker +👨‍🏭 man factory worker +👩‍🏭 woman factory worker +🧑‍💼 office worker +👨‍💼 man office worker +👩‍💼 woman office worker +🧑‍🔬 scientist +👨‍🔬 man scientist +👩‍🔬 woman scientist +🧑‍💻 technologist +👨‍💻 man technologist +👩‍💻 woman technologist +🧑‍🎤 singer +👨‍🎤 man singer +👩‍🎤 woman singer +🧑‍🎨 artist +👨‍🎨 man artist +👩‍🎨 woman artist +🧑‍✈️ pilot +👨‍✈️ man pilot +👩‍✈️ woman pilot +🧑‍🚀 astronaut +👨‍🚀 man astronaut +👩‍🚀 woman astronaut +🧑‍🚒 firefighter +👨‍🚒 man firefighter +👩‍🚒 woman firefighter +👮 police officer +👮‍♂️ man police officer +👮‍♀️ woman police officer +🕵️ detective +🕵️‍♂️ man detective +🕵️‍♀️ woman detective +💂 guard +💂‍♂️ man guard +💂‍♀️ woman guard +🥷 ninja +👷 construction worker +👷‍♂️ man construction worker +👷‍♀️ woman construction worker +🫅 person with crown +🤴 prince +👸 princess +👳 person wearing turban +👳‍♂️ man wearing turban +👳‍♀️ woman wearing turban +👲 person with skullcap +🧕 woman with headscarf +🤵 person in tuxedo +🤵‍♂️ man in tuxedo +🤵‍♀️ woman in tuxedo +👰 person with veil +👰‍♂️ man with veil +👰‍♀️ woman with veil +🤰 pregnant woman +🫃 pregnant man +🫄 pregnant person +🤱 breast-feeding +👩‍🍼 woman feeding baby +👨‍🍼 man feeding baby +🧑‍🍼 person feeding baby +👼 baby angel +🎅 Santa Claus +🤶 Mrs. Claus +🧑‍🎄 mx claus +🦸 superhero +🦸‍♂️ man superhero +🦸‍♀️ woman superhero +🦹 supervillain +🦹‍♂️ man supervillain +🦹‍♀️ woman supervillain +🧙 mage +🧙‍♂️ man mage +🧙‍♀️ woman mage +🧚 fairy +🧚‍♂️ man fairy +🧚‍♀️ woman fairy +🧛 vampire +🧛‍♂️ man vampire +🧛‍♀️ woman vampire +🧜 merperson +🧜‍♂️ merman +🧜‍♀️ mermaid +🧝 elf +🧝‍♂️ man elf +🧝‍♀️ woman elf +🧞 genie +🧞‍♂️ man genie +🧞‍♀️ woman genie +🧟 zombie +🧟‍♂️ man zombie +🧟‍♀️ woman zombie +🧌 troll +💆 person getting massage +💆‍♂️ man getting massage +💆‍♀️ woman getting massage +💇 person getting haircut +💇‍♂️ man getting haircut +💇‍♀️ woman getting haircut +🚶 person walking +🚶‍♂️ man walking +🚶‍♀️ woman walking +🧍 person standing +🧍‍♂️ man standing +🧍‍♀️ woman standing +🧎 person kneeling +🧎‍♂️ man kneeling +🧎‍♀️ woman kneeling +🧑‍🦯 person with white cane +👨‍🦯 man with white cane +👩‍🦯 woman with white cane +🧑‍🦼 person in motorized wheelchair +👨‍🦼 man in motorized wheelchair +👩‍🦼 woman in motorized wheelchair +🧑‍🦽 person in manual wheelchair +👨‍🦽 man in manual wheelchair +👩‍🦽 woman in manual wheelchair +🏃 person running +🏃‍♂️ man running +🏃‍♀️ woman running +💃 woman dancing +🕺 man dancing +🕴️ person in suit levitating +👯 people with bunny ears +👯‍♂️ men with bunny ears +👯‍♀️ women with bunny ears +🧖 person in steamy room +🧖‍♂️ man in steamy room +🧖‍♀️ woman in steamy room +🧗 person climbing +🧗‍♂️ man climbing +🧗‍♀️ woman climbing +🤺 person fencing +🏇 horse racing +⛷️ skier +🏂 snowboarder +🏌️ person golfing +🏌️‍♂️ man golfing +🏌️‍♀️ woman golfing +🏄 person surfing +🏄‍♂️ man surfing +🏄‍♀️ woman surfing +🚣 person rowing boat +🚣‍♂️ man rowing boat +🚣‍♀️ woman rowing boat +🏊 person swimming +🏊‍♂️ man swimming +🏊‍♀️ woman swimming +⛹️ person bouncing ball +⛹️‍♂️ man bouncing ball +⛹️‍♀️ woman bouncing ball +🏋️ person lifting weights +🏋️‍♂️ man lifting weights +🏋️‍♀️ woman lifting weights +🚴 person biking +🚴‍♂️ man biking +🚴‍♀️ woman biking +🚵 person mountain biking +🚵‍♂️ man mountain biking +🚵‍♀️ woman mountain biking +🤸 person cartwheeling +🤸‍♂️ man cartwheeling +🤸‍♀️ woman cartwheeling +🤼 people wrestling +🤼‍♂️ men wrestling +🤼‍♀️ women wrestling +🤽 person playing water polo +🤽‍♂️ man playing water polo +🤽‍♀️ woman playing water polo +🤾 person playing handball +🤾‍♂️ man playing handball +🤾‍♀️ woman playing handball +🤹 person juggling +🤹‍♂️ man juggling +🤹‍♀️ woman juggling +🧘 person in lotus position +🧘‍♂️ man in lotus position +🧘‍♀️ woman in lotus position +🛀 person taking bath +🛌 person in bed +🧑‍🤝‍🧑 people holding hands +👭 women holding hands +👫 woman and man holding hands +👬 men holding hands +💏 kiss +👩‍❤️‍💋‍👨 kiss: woman, man +👨‍❤️‍💋‍👨 kiss: man, man +👩‍❤️‍💋‍👩 kiss: woman, woman +💑 couple with heart +👩‍❤️‍👨 couple with heart: woman, man +👨‍❤️‍👨 couple with heart: man, man +👩‍❤️‍👩 couple with heart: woman, woman +👪 family +👨‍👩‍👦 family: man, woman, boy +👨‍👩‍👧 family: man, woman, girl +👨‍👩‍👧‍👦 family: man, woman, girl, boy +👨‍👩‍👦‍👦 family: man, woman, boy, boy +👨‍👩‍👧‍👧 family: man, woman, girl, girl +👨‍👨‍👦 family: man, man, boy +👨‍👨‍👧 family: man, man, girl +👨‍👨‍👧‍👦 family: man, man, girl, boy +👨‍👨‍👦‍👦 family: man, man, boy, boy +👨‍👨‍👧‍👧 family: man, man, girl, girl +👩‍👩‍👦 family: woman, woman, boy +👩‍👩‍👧 family: woman, woman, girl +👩‍👩‍👧‍👦 family: woman, woman, girl, boy +👩‍👩‍👦‍👦 family: woman, woman, boy, boy +👩‍👩‍👧‍👧 family: woman, woman, girl, girl +👨‍👦 family: man, boy +👨‍👦‍👦 family: man, boy, boy +👨‍👧 family: man, girl +👨‍👧‍👦 family: man, girl, boy +👨‍👧‍👧 family: man, girl, girl +👩‍👦 family: woman, boy +👩‍👦‍👦 family: woman, boy, boy +👩‍👧 family: woman, girl +👩‍👧‍👦 family: woman, girl, boy +👩‍👧‍👧 family: woman, girl, girl +🗣️ speaking head +👤 bust in silhouette +👥 busts in silhouette +🫂 people hugging +👣 footprints +@Animals & Nature +🐵 monkey face +🐒 monkey +🦍 gorilla +🦧 orangutan +🐶 dog face +🐕 dog +🦮 guide dog +🐕‍🦺 service dog +🐩 poodle +🐺 wolf +🦊 fox +🦝 raccoon +🐱 cat face +🐈 cat +🐈‍⬛ black cat +🦁 lion +🐯 tiger face +🐅 tiger +🐆 leopard +🐴 horse face +🫎 moose +🫏 donkey +🐎 horse +🦄 unicorn +🦓 zebra +🦌 deer +🦬 bison +🐮 cow face +🐂 ox +🐃 water buffalo +🐄 cow +🐷 pig face +🐖 pig +🐗 boar +🐽 pig nose +🐏 ram +🐑 ewe +🐐 goat +🐪 camel +🐫 two-hump camel +🦙 llama +🦒 giraffe +🐘 elephant +🦣 mammoth +🦏 rhinoceros +🦛 hippopotamus +🐭 mouse face +🐁 mouse +🐀 rat +🐹 hamster +🐰 rabbit face +🐇 rabbit +🐿️ chipmunk +🦫 beaver +🦔 hedgehog +🦇 bat +🐻 bear +🐻‍❄️ polar bear +🐨 koala +🐼 panda +🦥 sloth +🦦 otter +🦨 skunk +🦘 kangaroo +🦡 badger +🐾 paw prints +🦃 turkey +🐔 chicken +🐓 rooster +🐣 hatching chick +🐤 baby chick +🐥 front-facing baby chick +🐦 bird +🐧 penguin +🕊️ dove +🦅 eagle +🦆 duck +🦢 swan +🦉 owl +🦤 dodo +🪶 feather +🦩 flamingo +🦚 peacock +🦜 parrot +🪽 wing +🐦‍⬛ black bird +🪿 goose +🐸 frog +🐊 crocodile +🐢 turtle +🦎 lizard +🐍 snake +🐲 dragon face +🐉 dragon +🦕 sauropod +🦖 T-Rex +🐳 spouting whale +🐋 whale +🐬 dolphin +🦭 seal +🐟 fish +🐠 tropical fish +🐡 blowfish +🦈 shark +🐙 octopus +🐚 spiral shell +🪸 coral +🪼 jellyfish +🐌 snail +🦋 butterfly +🐛 bug +🐜 ant +🐝 honeybee +🪲 beetle +🐞 lady beetle +🦗 cricket +🪳 cockroach +🕷️ spider +🕸️ spider web +🦂 scorpion +🦟 mosquito +🪰 fly +🪱 worm +🦠 microbe +💐 bouquet +🌸 cherry blossom +💮 white flower +🪷 lotus +🏵️ rosette +🌹 rose +🥀 wilted flower +🌺 hibiscus +🌻 sunflower +🌼 blossom +🌷 tulip +🪻 hyacinth +🌱 seedling +🪴 potted plant +🌲 evergreen tree +🌳 deciduous tree +🌴 palm tree +🌵 cactus +🌾 sheaf of rice +🌿 herb +☘️ shamrock +🍀 four leaf clover +🍁 maple leaf +🍂 fallen leaf +🍃 leaf fluttering in wind +🪹 empty nest +🪺 nest with eggs +🍄 mushroom +@Food & Drink +🍇 grapes +🍈 melon +🍉 watermelon +🍊 tangerine +🍋 lemon +🍌 banana +🍍 pineapple +🥭 mango +🍎 red apple +🍏 green apple +🍐 pear +🍑 peach +🍒 cherries +🍓 strawberry +🫐 blueberries +🥝 kiwi fruit +🍅 tomato +🫒 olive +🥥 coconut +🥑 avocado +🍆 eggplant +🥔 potato +🥕 carrot +🌽 ear of corn +🌶️ hot pepper +🫑 bell pepper +🥒 cucumber +🥬 leafy green +🥦 broccoli +🧄 garlic +🧅 onion +🥜 peanuts +🫘 beans +🌰 chestnut +🫚 ginger root +🫛 pea pod +🍞 bread +🥐 croissant +🥖 baguette bread +🫓 flatbread +🥨 pretzel +🥯 bagel +🥞 pancakes +🧇 waffle +🧀 cheese wedge +🍖 meat on bone +🍗 poultry leg +🥩 cut of meat +🥓 bacon +🍔 hamburger +🍟 french fries +🍕 pizza +🌭 hot dog +🥪 sandwich +🌮 taco +🌯 burrito +🫔 tamale +🥙 stuffed flatbread +🧆 falafel +🥚 egg +🍳 cooking +🥘 shallow pan of food +🍲 pot of food +🫕 fondue +🥣 bowl with spoon +🥗 green salad +🍿 popcorn +🧈 butter +🧂 salt +🥫 canned food +🍱 bento box +🍘 rice cracker +🍙 rice ball +🍚 cooked rice +🍛 curry rice +🍜 steaming bowl +🍝 spaghetti +🍠 roasted sweet potato +🍢 oden +🍣 sushi +🍤 fried shrimp +🍥 fish cake with swirl +🥮 moon cake +🍡 dango +🥟 dumpling +🥠 fortune cookie +🥡 takeout box +🦀 crab +🦞 lobster +🦐 shrimp +🦑 squid +🦪 oyster +🍦 soft ice cream +🍧 shaved ice +🍨 ice cream +🍩 doughnut +🍪 cookie +🎂 birthday cake +🍰 shortcake +🧁 cupcake +🥧 pie +🍫 chocolate bar +🍬 candy +🍭 lollipop +🍮 custard +🍯 honey pot +🍼 baby bottle +🥛 glass of milk +☕ hot beverage +🫖 teapot +🍵 teacup without handle +🍶 sake +🍾 bottle with popping cork +🍷 wine glass +🍸 cocktail glass +🍹 tropical drink +🍺 beer mug +🍻 clinking beer mugs +🥂 clinking glasses +🥃 tumbler glass +🫗 pouring liquid +🥤 cup with straw +🧋 bubble tea +🧃 beverage box +🧉 mate +🧊 ice +🥢 chopsticks +🍽️ fork and knife with plate +🍴 fork and knife +🥄 spoon +🔪 kitchen knife +🫙 jar +🏺 amphora +@Travel & Places +🌍 globe showing Europe-Africa +🌎 globe showing Americas +🌏 globe showing Asia-Australia +🌐 globe with meridians +🗺️ world map +🗾 map of Japan +🧭 compass +🏔️ snow-capped mountain +⛰️ mountain +🌋 volcano +🗻 mount fuji +🏕️ camping +🏖️ beach with umbrella +🏜️ desert +🏝️ desert island +🏞️ national park +🏟️ stadium +🏛️ classical building +🏗️ building construction +🧱 brick +🪨 rock +🪵 wood +🛖 hut +🏘️ houses +🏚️ derelict house +🏠 house +🏡 house with garden +🏢 office building +🏣 Japanese post office +🏤 post office +🏥 hospital +🏦 bank +🏨 hotel +🏩 love hotel +🏪 convenience store +🏫 school +🏬 department store +🏭 factory +🏯 Japanese castle +🏰 castle +💒 wedding +🗼 Tokyo tower +🗽 Statue of Liberty +⛪ church +🕌 mosque +🛕 hindu temple +🕍 synagogue +⛩️ shinto shrine +🕋 kaaba +⛲ fountain +⛺ tent +🌁 foggy +🌃 night with stars +🏙️ cityscape +🌄 sunrise over mountains +🌅 sunrise +🌆 cityscape at dusk +🌇 sunset +🌉 bridge at night +♨️ hot springs +🎠 carousel horse +🛝 playground slide +🎡 ferris wheel +🎢 roller coaster +💈 barber pole +🎪 circus tent +🚂 locomotive +🚃 railway car +🚄 high-speed train +🚅 bullet train +🚆 train +🚇 metro +🚈 light rail +🚉 station +🚊 tram +🚝 monorail +🚞 mountain railway +🚋 tram car +🚌 bus +🚍 oncoming bus +🚎 trolleybus +🚐 minibus +🚑 ambulance +🚒 fire engine +🚓 police car +🚔 oncoming police car +🚕 taxi +🚖 oncoming taxi +🚗 automobile +🚘 oncoming automobile +🚙 sport utility vehicle +🛻 pickup truck +🚚 delivery truck +🚛 articulated lorry +🚜 tractor +🏎️ racing car +🏍️ motorcycle +🛵 motor scooter +🦽 manual wheelchair +🦼 motorized wheelchair +🛺 auto rickshaw +🚲 bicycle +🛴 kick scooter +🛹 skateboard +🛼 roller skate +🚏 bus stop +🛣️ motorway +🛤️ railway track +🛢️ oil drum +⛽ fuel pump +🛞 wheel +🚨 police car light +🚥 horizontal traffic light +🚦 vertical traffic light +🛑 stop sign +🚧 construction +⚓ anchor +🛟 ring buoy +⛵ sailboat +🛶 canoe +🚤 speedboat +🛳️ passenger ship +⛴️ ferry +🛥️ motor boat +🚢 ship +✈️ airplane +🛩️ small airplane +🛫 airplane departure +🛬 airplane arrival +🪂 parachute +💺 seat +🚁 helicopter +🚟 suspension railway +🚠 mountain cableway +🚡 aerial tramway +🛰️ satellite +🚀 rocket +🛸 flying saucer +🛎️ bellhop bell +🧳 luggage +⌛ hourglass done +⏳ hourglass not done +⌚ watch +⏰ alarm clock +⏱️ stopwatch +⏲️ timer clock +🕰️ mantelpiece clock +🕛 twelve o’clock +🕧 twelve-thirty +🕐 one o’clock +🕜 one-thirty +🕑 two o’clock +🕝 two-thirty +🕒 three o’clock +🕞 three-thirty +🕓 four o’clock +🕟 four-thirty +🕔 five o’clock +🕠 five-thirty +🕕 six o’clock +🕡 six-thirty +🕖 seven o’clock +🕢 seven-thirty +🕗 eight o’clock +🕣 eight-thirty +🕘 nine o’clock +🕤 nine-thirty +🕙 ten o’clock +🕥 ten-thirty +🕚 eleven o’clock +🕦 eleven-thirty +🌑 new moon +🌒 waxing crescent moon +🌓 first quarter moon +🌔 waxing gibbous moon +🌕 full moon +🌖 waning gibbous moon +🌗 last quarter moon +🌘 waning crescent moon +🌙 crescent moon +🌚 new moon face +🌛 first quarter moon face +🌜 last quarter moon face +🌡️ thermometer +☀️ sun +🌝 full moon face +🌞 sun with face +🪐 ringed planet +⭐ star +🌟 glowing star +🌠 shooting star +🌌 milky way +☁️ cloud +⛅ sun behind cloud +⛈️ cloud with lightning and rain +🌤️ sun behind small cloud +🌥️ sun behind large cloud +🌦️ sun behind rain cloud +🌧️ cloud with rain +🌨️ cloud with snow +🌩️ cloud with lightning +🌪️ tornado +🌫️ fog +🌬️ wind face +🌀 cyclone +🌈 rainbow +🌂 closed umbrella +☂️ umbrella +☔ umbrella with rain drops +⛱️ umbrella on ground +⚡ high voltage +❄️ snowflake +☃️ snowman +⛄ snowman without snow +☄️ comet +🔥 fire +💧 droplet +🌊 water wave +@Activities +🎃 jack-o-lantern +🎄 Christmas tree +🎆 fireworks +🎇 sparkler +🧨 firecracker +✨ sparkles +🎈 balloon +🎉 party popper +🎊 confetti ball +🎋 tanabata tree +🎍 pine decoration +🎎 Japanese dolls +🎏 carp streamer +🎐 wind chime +🎑 moon viewing ceremony +🧧 red envelope +🎀 ribbon +🎁 wrapped gift +🎗️ reminder ribbon +🎟️ admission tickets +🎫 ticket +🎖️ military medal +🏆 trophy +🏅 sports medal +🥇 1st place medal +🥈 2nd place medal +🥉 3rd place medal +⚽ soccer ball +⚾ baseball +🥎 softball +🏀 basketball +🏐 volleyball +🏈 american football +🏉 rugby football +🎾 tennis +🥏 flying disc +🎳 bowling +🏏 cricket game +🏑 field hockey +🏒 ice hockey +🥍 lacrosse +🏓 ping pong +🏸 badminton +🥊 boxing glove +🥋 martial arts uniform +🥅 goal net +⛳ flag in hole +⛸️ ice skate +🎣 fishing pole +🤿 diving mask +🎽 running shirt +🎿 skis +🛷 sled +🥌 curling stone +🎯 bullseye +🪀 yo-yo +🪁 kite +🔫 water pistol +🎱 pool 8 ball +🔮 crystal ball +🪄 magic wand +🎮 video game +🕹️ joystick +🎰 slot machine +🎲 game die +🧩 puzzle piece +🧸 teddy bear +🪅 piñata +🪩 mirror ball +🪆 nesting dolls +♠️ spade suit +♥️ heart suit +♦️ diamond suit +♣️ club suit +♟️ chess pawn +🃏 joker +🀄 mahjong red dragon +🎴 flower playing cards +🎭 performing arts +🖼️ framed picture +🎨 artist palette +🧵 thread +🪡 sewing needle +🧶 yarn +🪢 knot +@Objects +👓 glasses +🕶️ sunglasses +🥽 goggles +🥼 lab coat +🦺 safety vest +👔 necktie +👕 t-shirt +👖 jeans +🧣 scarf +🧤 gloves +🧥 coat +🧦 socks +👗 dress +👘 kimono +🥻 sari +🩱 one-piece swimsuit +🩲 briefs +🩳 shorts +👙 bikini +👚 woman’s clothes +🪭 folding hand fan +👛 purse +👜 handbag +👝 clutch bag +🛍️ shopping bags +🎒 backpack +🩴 thong sandal +👞 man’s shoe +👟 running shoe +🥾 hiking boot +🥿 flat shoe +👠 high-heeled shoe +👡 woman’s sandal +🩰 ballet shoes +👢 woman’s boot +🪮 hair pick +👑 crown +👒 woman’s hat +🎩 top hat +🎓 graduation cap +🧢 billed cap +🪖 military helmet +⛑️ rescue worker’s helmet +📿 prayer beads +💄 lipstick +💍 ring +💎 gem stone +🔇 muted speaker +🔈 speaker low volume +🔉 speaker medium volume +🔊 speaker high volume +📢 loudspeaker +📣 megaphone +📯 postal horn +🔔 bell +🔕 bell with slash +🎼 musical score +🎵 musical note +🎶 musical notes +🎙️ studio microphone +🎚️ level slider +🎛️ control knobs +🎤 microphone +🎧 headphone +📻 radio +🎷 saxophone +🪗 accordion +🎸 guitar +🎹 musical keyboard +🎺 trumpet +🎻 violin +🪕 banjo +🥁 drum +🪘 long drum +🪇 maracas +🪈 flute +📱 mobile phone +📲 mobile phone with arrow +☎️ telephone +📞 telephone receiver +📟 pager +📠 fax machine +🔋 battery +🪫 low battery +🔌 electric plug +💻 laptop +🖥️ desktop computer +🖨️ printer +⌨️ keyboard +🖱️ computer mouse +🖲️ trackball +💽 computer disk +💾 floppy disk +💿 optical disk +📀 dvd +🧮 abacus +🎥 movie camera +🎞️ film frames +📽️ film projector +🎬 clapper board +📺 television +📷 camera +📸 camera with flash +📹 video camera +📼 videocassette +🔍 magnifying glass tilted left +🔎 magnifying glass tilted right +🕯️ candle +💡 light bulb +🔦 flashlight +🏮 red paper lantern +🪔 diya lamp +📔 notebook with decorative cover +📕 closed book +📖 open book +📗 green book +📘 blue book +📙 orange book +📚 books +📓 notebook +📒 ledger +📃 page with curl +📜 scroll +📄 page facing up +📰 newspaper +🗞️ rolled-up newspaper +📑 bookmark tabs +🔖 bookmark +🏷️ label +💰 money bag +🪙 coin +💴 yen banknote +💵 dollar banknote +💶 euro banknote +💷 pound banknote +💸 money with wings +💳 credit card +🧾 receipt +💹 chart increasing with yen +✉️ envelope +📧 e-mail +📨 incoming envelope +📩 envelope with arrow +📤 outbox tray +📥 inbox tray +📦 package +📫 closed mailbox with raised flag +📪 closed mailbox with lowered flag +📬 open mailbox with raised flag +📭 open mailbox with lowered flag +📮 postbox +🗳️ ballot box with ballot +✏️ pencil +✒️ black nib +🖋️ fountain pen +🖊️ pen +🖌️ paintbrush +🖍️ crayon +📝 memo +💼 briefcase +📁 file folder +📂 open file folder +🗂️ card index dividers +📅 calendar +📆 tear-off calendar +🗒️ spiral notepad +🗓️ spiral calendar +📇 card index +📈 chart increasing +📉 chart decreasing +📊 bar chart +📋 clipboard +📌 pushpin +📍 round pushpin +📎 paperclip +🖇️ linked paperclips +📏 straight ruler +📐 triangular ruler +✂️ scissors +🗃️ card file box +🗄️ file cabinet +🗑️ wastebasket +🔒 locked +🔓 unlocked +🔏 locked with pen +🔐 locked with key +🔑 key +🗝️ old key +🔨 hammer +🪓 axe +⛏️ pick +⚒️ hammer and pick +🛠️ hammer and wrench +🗡️ dagger +⚔️ crossed swords +💣 bomb +🪃 boomerang +🏹 bow and arrow +🛡️ shield +🪚 carpentry saw +🔧 wrench +🪛 screwdriver +🔩 nut and bolt +⚙️ gear +🗜️ clamp +⚖️ balance scale +🦯 white cane +🔗 link +⛓️ chains +🪝 hook +🧰 toolbox +🧲 magnet +🪜 ladder +⚗️ alembic +🧪 test tube +🧫 petri dish +🧬 dna +🔬 microscope +🔭 telescope +📡 satellite antenna +💉 syringe +🩸 drop of blood +💊 pill +🩹 adhesive bandage +🩼 crutch +🩺 stethoscope +🩻 x-ray +🚪 door +🛗 elevator +🪞 mirror +🪟 window +🛏️ bed +🛋️ couch and lamp +🪑 chair +🚽 toilet +🪠 plunger +🚿 shower +🛁 bathtub +🪤 mouse trap +🪒 razor +🧴 lotion bottle +🧷 safety pin +🧹 broom +🧺 basket +🧻 roll of paper +🪣 bucket +🧼 soap +🫧 bubbles +🪥 toothbrush +🧽 sponge +🧯 fire extinguisher +🛒 shopping cart +🚬 cigarette +⚰️ coffin +🪦 headstone +⚱️ funeral urn +🧿 nazar amulet +🪬 hamsa +🗿 moai +🪧 placard +🪪 identification card +@Symbols +🏧 ATM sign +🚮 litter in bin sign +🚰 potable water +♿ wheelchair symbol +🚹 men’s room +🚺 women’s room +🚻 restroom +🚼 baby symbol +🚾 water closet +🛂 passport control +🛃 customs +🛄 baggage claim +🛅 left luggage +⚠️ warning +🚸 children crossing +⛔ no entry +🚫 prohibited +🚳 no bicycles +🚭 no smoking +🚯 no littering +🚱 non-potable water +🚷 no pedestrians +📵 no mobile phones +🔞 no one under eighteen +☢️ radioactive +☣️ biohazard +⬆️ up arrow +↗️ up-right arrow +➡️ right arrow +↘️ down-right arrow +⬇️ down arrow +↙️ down-left arrow +⬅️ left arrow +↖️ up-left arrow +↕️ up-down arrow +↔️ left-right arrow +↩️ right arrow curving left +↪️ left arrow curving right +⤴️ right arrow curving up +⤵️ right arrow curving down +🔃 clockwise vertical arrows +🔄 counterclockwise arrows button +🔙 BACK arrow +🔚 END arrow +🔛 ON! arrow +🔜 SOON arrow +🔝 TOP arrow +🛐 place of worship +⚛️ atom symbol +🕉️ om +✡️ star of David +☸️ wheel of dharma +☯️ yin yang +✝️ latin cross +☦️ orthodox cross +☪️ star and crescent +☮️ peace symbol +🕎 menorah +🔯 dotted six-pointed star +🪯 khanda +♈ Aries +♉ Taurus +♊ Gemini +♋ Cancer +♌ Leo +♍ Virgo +♎ Libra +♏ Scorpio +♐ Sagittarius +♑ Capricorn +♒ Aquarius +♓ Pisces +⛎ Ophiuchus +🔀 shuffle tracks button +🔁 repeat button +🔂 repeat single button +▶️ play button +⏩ fast-forward button +⏭️ next track button +⏯️ play or pause button +◀️ reverse button +⏪ fast reverse button +⏮️ last track button +🔼 upwards button +⏫ fast up button +🔽 downwards button +⏬ fast down button +⏸️ pause button +⏹️ stop button +⏺️ record button +⏏️ eject button +🎦 cinema +🔅 dim button +🔆 bright button +📶 antenna bars +🛜 wireless +📳 vibration mode +📴 mobile phone off +♀️ female sign +♂️ male sign +⚧️ transgender symbol +✖️ multiply +➕ plus +➖ minus +➗ divide +🟰 heavy equals sign +♾️ infinity +‼️ double exclamation mark +⁉️ exclamation question mark +❓ red question mark +❔ white question mark +❕ white exclamation mark +❗ red exclamation mark +〰️ wavy dash +💱 currency exchange +💲 heavy dollar sign +⚕️ medical symbol +♻️ recycling symbol +⚜️ fleur-de-lis +🔱 trident emblem +📛 name badge +🔰 Japanese symbol for beginner +⭕ hollow red circle +✅ check mark button +☑️ check box with check +✔️ check mark +❌ cross mark +❎ cross mark button +➰ curly loop +➿ double curly loop +〽️ part alternation mark +✳️ eight-spoked asterisk +✴️ eight-pointed star +❇️ sparkle +©️ copyright +®️ registered +™️ trade mark +#️⃣ keycap: # +*️⃣ keycap: * +0️⃣ keycap: 0 +1️⃣ keycap: 1 +2️⃣ keycap: 2 +3️⃣ keycap: 3 +4️⃣ keycap: 4 +5️⃣ keycap: 5 +6️⃣ keycap: 6 +7️⃣ keycap: 7 +8️⃣ keycap: 8 +9️⃣ keycap: 9 +🔟 keycap: 10 +🔠 input latin uppercase +🔡 input latin lowercase +🔢 input numbers +🔣 input symbols +🔤 input latin letters +🅰️ A button (blood type) +🆎 AB button (blood type) +🅱️ B button (blood type) +🆑 CL button +🆒 COOL button +🆓 FREE button +ℹ️ information +🆔 ID button +Ⓜ️ circled M +🆕 NEW button +🆖 NG button +🅾️ O button (blood type) +🆗 OK button +🅿️ P button +🆘 SOS button +🆙 UP! button +🆚 VS button +🈁 Japanese “here” button +🈂️ Japanese “service charge” button +🈷️ Japanese “monthly amount” button +🈶 Japanese “not free of charge” button +🈯 Japanese “reserved” button +🉐 Japanese “bargain” button +🈹 Japanese “discount” button +🈚 Japanese “free of charge” button +🈲 Japanese “prohibited” button +🉑 Japanese “acceptable” button +🈸 Japanese “application” button +🈴 Japanese “passing grade” button +🈳 Japanese “vacancy” button +㊗️ Japanese “congratulations” button +㊙️ Japanese “secret” button +🈺 Japanese “open for business” button +🈵 Japanese “no vacancy” button +🔴 red circle +🟠 orange circle +🟡 yellow circle +🟢 green circle +🔵 blue circle +🟣 purple circle +🟤 brown circle +⚫ black circle +⚪ white circle +🟥 red square +🟧 orange square +🟨 yellow square +🟩 green square +🟦 blue square +🟪 purple square +🟫 brown square +⬛ black large square +⬜ white large square +◼️ black medium square +◻️ white medium square +◾ black medium-small square +◽ white medium-small square +▪️ black small square +▫️ white small square +🔶 large orange diamond +🔷 large blue diamond +🔸 small orange diamond +🔹 small blue diamond +🔺 red triangle pointed up +🔻 red triangle pointed down +💠 diamond with a dot +🔘 radio button +🔳 white square button +🔲 black square button +@Flags +🏁 chequered flag +🚩 triangular flag +🎌 crossed flags +🏴 black flag +🏳️ white flag +🏳️‍🌈 rainbow flag +🏳️‍⚧️ transgender flag +🏴‍☠️ pirate flag +🇦🇨 flag: Ascension Island +🇦🇩 flag: Andorra +🇦🇪 flag: United Arab Emirates +🇦🇫 flag: Afghanistan +🇦🇬 flag: Antigua & Barbuda +🇦🇮 flag: Anguilla +🇦🇱 flag: Albania +🇦🇲 flag: Armenia +🇦🇴 flag: Angola +🇦🇶 flag: Antarctica +🇦🇷 flag: Argentina +🇦🇸 flag: American Samoa +🇦🇹 flag: Austria +🇦🇺 flag: Australia +🇦🇼 flag: Aruba +🇦🇽 flag: Åland Islands +🇦🇿 flag: Azerbaijan +🇧🇦 flag: Bosnia & Herzegovina +🇧🇧 flag: Barbados +🇧🇩 flag: Bangladesh +🇧🇪 flag: Belgium +🇧🇫 flag: Burkina Faso +🇧🇬 flag: Bulgaria +🇧🇭 flag: Bahrain +🇧🇮 flag: Burundi +🇧🇯 flag: Benin +🇧🇱 flag: St. Barthélemy +🇧🇲 flag: Bermuda +🇧🇳 flag: Brunei +🇧🇴 flag: Bolivia +🇧🇶 flag: Caribbean Netherlands +🇧🇷 flag: Brazil +🇧🇸 flag: Bahamas +🇧🇹 flag: Bhutan +🇧🇻 flag: Bouvet Island +🇧🇼 flag: Botswana +🇧🇾 flag: Belarus +🇧🇿 flag: Belize +🇨🇦 flag: Canada +🇨🇨 flag: Cocos (Keeling) Islands +🇨🇩 flag: Congo - Kinshasa +🇨🇫 flag: Central African Republic +🇨🇬 flag: Congo - Brazzaville +🇨🇭 flag: Switzerland +🇨🇮 flag: Côte d’Ivoire +🇨🇰 flag: Cook Islands +🇨🇱 flag: Chile +🇨🇲 flag: Cameroon +🇨🇳 flag: China +🇨🇴 flag: Colombia +🇨🇵 flag: Clipperton Island +🇨🇷 flag: Costa Rica +🇨🇺 flag: Cuba +🇨🇻 flag: Cape Verde +🇨🇼 flag: Curaçao +🇨🇽 flag: Christmas Island +🇨🇾 flag: Cyprus +🇨🇿 flag: Czechia +🇩🇪 flag: Germany +🇩🇬 flag: Diego Garcia +🇩🇯 flag: Djibouti +🇩🇰 flag: Denmark +🇩🇲 flag: Dominica +🇩🇴 flag: Dominican Republic +🇩🇿 flag: Algeria +🇪🇦 flag: Ceuta & Melilla +🇪🇨 flag: Ecuador +🇪🇪 flag: Estonia +🇪🇬 flag: Egypt +🇪🇭 flag: Western Sahara +🇪🇷 flag: Eritrea +🇪🇸 flag: Spain +🇪🇹 flag: Ethiopia +🇪🇺 flag: European Union +🇫🇮 flag: Finland +🇫🇯 flag: Fiji +🇫🇰 flag: Falkland Islands +🇫🇲 flag: Micronesia +🇫🇴 flag: Faroe Islands +🇫🇷 flag: France +🇬🇦 flag: Gabon +🇬🇧 flag: United Kingdom +🇬🇩 flag: Grenada +🇬🇪 flag: Georgia +🇬🇫 flag: French Guiana +🇬🇬 flag: Guernsey +🇬🇭 flag: Ghana +🇬🇮 flag: Gibraltar +🇬🇱 flag: Greenland +🇬🇲 flag: Gambia +🇬🇳 flag: Guinea +🇬🇵 flag: Guadeloupe +🇬🇶 flag: Equatorial Guinea +🇬🇷 flag: Greece +🇬🇸 flag: South Georgia & South Sandwich Islands +🇬🇹 flag: Guatemala +🇬🇺 flag: Guam +🇬🇼 flag: Guinea-Bissau +🇬🇾 flag: Guyana +🇭🇰 flag: Hong Kong SAR China +🇭🇲 flag: Heard & McDonald Islands +🇭🇳 flag: Honduras +🇭🇷 flag: Croatia +🇭🇹 flag: Haiti +🇭🇺 flag: Hungary +🇮🇨 flag: Canary Islands +🇮🇩 flag: Indonesia +🇮🇪 flag: Ireland +🇮🇱 flag: Israel +🇮🇲 flag: Isle of Man +🇮🇳 flag: India +🇮🇴 flag: British Indian Ocean Territory +🇮🇶 flag: Iraq +🇮🇷 flag: Iran +🇮🇸 flag: Iceland +🇮🇹 flag: Italy +🇯🇪 flag: Jersey +🇯🇲 flag: Jamaica +🇯🇴 flag: Jordan +🇯🇵 flag: Japan +🇰🇪 flag: Kenya +🇰🇬 flag: Kyrgyzstan +🇰🇭 flag: Cambodia +🇰🇮 flag: Kiribati +🇰🇲 flag: Comoros +🇰🇳 flag: St. Kitts & Nevis +🇰🇵 flag: North Korea +🇰🇷 flag: South Korea +🇰🇼 flag: Kuwait +🇰🇾 flag: Cayman Islands +🇰🇿 flag: Kazakhstan +🇱🇦 flag: Laos +🇱🇧 flag: Lebanon +🇱🇨 flag: St. Lucia +🇱🇮 flag: Liechtenstein +🇱🇰 flag: Sri Lanka +🇱🇷 flag: Liberia +🇱🇸 flag: Lesotho +🇱🇹 flag: Lithuania +🇱🇺 flag: Luxembourg +🇱🇻 flag: Latvia +🇱🇾 flag: Libya +🇲🇦 flag: Morocco +🇲🇨 flag: Monaco +🇲🇩 flag: Moldova +🇲🇪 flag: Montenegro +🇲🇫 flag: St. Martin +🇲🇬 flag: Madagascar +🇲🇭 flag: Marshall Islands +🇲🇰 flag: North Macedonia +🇲🇱 flag: Mali +🇲🇲 flag: Myanmar (Burma) +🇲🇳 flag: Mongolia +🇲🇴 flag: Macao SAR China +🇲🇵 flag: Northern Mariana Islands +🇲🇶 flag: Martinique +🇲🇷 flag: Mauritania +🇲🇸 flag: Montserrat +🇲🇹 flag: Malta +🇲🇺 flag: Mauritius +🇲🇻 flag: Maldives +🇲🇼 flag: Malawi +🇲🇽 flag: Mexico +🇲🇾 flag: Malaysia +🇲🇿 flag: Mozambique +🇳🇦 flag: Namibia +🇳🇨 flag: New Caledonia +🇳🇪 flag: Niger +🇳🇫 flag: Norfolk Island +🇳🇬 flag: Nigeria +🇳🇮 flag: Nicaragua +🇳🇱 flag: Netherlands +🇳🇴 flag: Norway +🇳🇵 flag: Nepal +🇳🇷 flag: Nauru +🇳🇺 flag: Niue +🇳🇿 flag: New Zealand +🇴🇲 flag: Oman +🇵🇦 flag: Panama +🇵🇪 flag: Peru +🇵🇫 flag: French Polynesia +🇵🇬 flag: Papua New Guinea +🇵🇭 flag: Philippines +🇵🇰 flag: Pakistan +🇵🇱 flag: Poland +🇵🇲 flag: St. Pierre & Miquelon +🇵🇳 flag: Pitcairn Islands +🇵🇷 flag: Puerto Rico +🇵🇸 flag: Palestinian Territories +🇵🇹 flag: Portugal +🇵🇼 flag: Palau +🇵🇾 flag: Paraguay +🇶🇦 flag: Qatar +🇷🇪 flag: Réunion +🇷🇴 flag: Romania +🇷🇸 flag: Serbia +🇷🇺 flag: Russia +🇷🇼 flag: Rwanda +🇸🇦 flag: Saudi Arabia +🇸🇧 flag: Solomon Islands +🇸🇨 flag: Seychelles +🇸🇩 flag: Sudan +🇸🇪 flag: Sweden +🇸🇬 flag: Singapore +🇸🇭 flag: St. Helena +🇸🇮 flag: Slovenia +🇸🇯 flag: Svalbard & Jan Mayen +🇸🇰 flag: Slovakia +🇸🇱 flag: Sierra Leone +🇸🇲 flag: San Marino +🇸🇳 flag: Senegal +🇸🇴 flag: Somalia +🇸🇷 flag: Suriname +🇸🇸 flag: South Sudan +🇸🇹 flag: São Tomé & Príncipe +🇸🇻 flag: El Salvador +🇸🇽 flag: Sint Maarten +🇸🇾 flag: Syria +🇸🇿 flag: Eswatini +🇹🇦 flag: Tristan da Cunha +🇹🇨 flag: Turks & Caicos Islands +🇹🇩 flag: Chad +🇹🇫 flag: French Southern Territories +🇹🇬 flag: Togo +🇹🇭 flag: Thailand +🇹🇯 flag: Tajikistan +🇹🇰 flag: Tokelau +🇹🇱 flag: Timor-Leste +🇹🇲 flag: Turkmenistan +🇹🇳 flag: Tunisia +🇹🇴 flag: Tonga +🇹🇷 flag: Turkey +🇹🇹 flag: Trinidad & Tobago +🇹🇻 flag: Tuvalu +🇹🇼 flag: Taiwan +🇹🇿 flag: Tanzania +🇺🇦 flag: Ukraine +🇺🇬 flag: Uganda +🇺🇲 flag: U.S. Outlying Islands +🇺🇳 flag: United Nations +🇺🇸 flag: United States +🇺🇾 flag: Uruguay +🇺🇿 flag: Uzbekistan +🇻🇦 flag: Vatican City +🇻🇨 flag: St. Vincent & Grenadines +🇻🇪 flag: Venezuela +🇻🇬 flag: British Virgin Islands +🇻🇮 flag: U.S. Virgin Islands +🇻🇳 flag: Vietnam +🇻🇺 flag: Vanuatu +🇼🇫 flag: Wallis & Futuna +🇼🇸 flag: Samoa +🇽🇰 flag: Kosovo +🇾🇪 flag: Yemen +🇾🇹 flag: Mayotte +🇿🇦 flag: South Africa +🇿🇲 flag: Zambia +🇿🇼 flag: Zimbabwe +🏴󠁧󠁢󠁥󠁮󠁧󠁿 flag: England +🏴󠁧󠁢󠁳󠁣󠁴󠁿 flag: Scotland +🏴󠁧󠁢󠁷󠁬󠁳󠁿 flag: Wales diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkFixtures.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkFixtures.kt new file mode 100644 index 0000000000..0723c12ec3 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkFixtures.kt @@ -0,0 +1,14 @@ +package chat.simplex.app + +internal const val BADGE_LINK_PREFIX = "simplexchat:/badge/code/" +internal const val BADGE_LINK = "${BADGE_LINK_PREFIX}SB0000000000000000000" +internal const val OTHER_BADGE_LINK = "${BADGE_LINK_PREFIX}SB1111111111111111111" + +internal fun badgeLinkOfBytes(size: Int): String = BADGE_LINK_PREFIX + "A".repeat(size - BADGE_LINK_PREFIX.length) + +internal const val SHORT_CONNECTION_LINK = "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" +private const val ONE_TIME_LINK_PREFIX = "simplex:/invitation#/?v=2-7&smp=smp%3A%2F%2Fexample&e2e=v%3D2-3%26kem_key%3D" +// an sntrup761 public key is 1158 bytes, 1544 characters in base64url +private const val KEM_KEY_CHARACTERS = 1544 + +internal fun oneTimeLinkWithKemKey(): String = ONE_TIME_LINK_PREFIX + "A".repeat(KEM_KEY_CHARACTERS) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkSchemeTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkSchemeTest.kt new file mode 100644 index 0000000000..b86ead9733 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinkSchemeTest.kt @@ -0,0 +1,680 @@ +package chat.simplex.app + +import chat.simplex.common.platform.APPIMAGE_ENTRY_NAME +import chat.simplex.common.platform.DesktopInstallation +import chat.simplex.common.platform.DesktopPlatform +import chat.simplex.common.platform.RegistryValue +import chat.simplex.common.platform.APP_LINK_MIME_TYPE +import chat.simplex.common.platform.CONNECTION_LINK_MIME_TYPE +import chat.simplex.common.platform.appImageDesktopEntry +import chat.simplex.common.platform.desktopExecArgument +import chat.simplex.common.platform.desktopInstallation +import chat.simplex.common.platform.linuxPackageRegistered +import chat.simplex.common.platform.onlyOwnerCanReplace +import chat.simplex.common.platform.onlyOwnerCanWrite +import chat.simplex.common.platform.registerAppImageScheme +import chat.simplex.common.platform.registerWindowsSchemes +import chat.simplex.common.platform.runProcess +import chat.simplex.common.platform.windowsCommandProgram +import chat.simplex.common.platform.windowsApplicationValues +import chat.simplex.common.platform.windowsOpenCommand +import chat.simplex.common.platform.WindowsShell +import chat.simplex.common.platform.windowsProgIdValues +import chat.simplex.common.platform.windowsSchemeValues +import com.sun.jna.platform.win32.Win32Exception +import com.sun.jna.platform.win32.WinError +import com.sun.jna.platform.win32.WinNT +import org.junit.Assume.assumeNoException +import org.junit.Assume.assumeTrue +import java.io.File +import java.lang.reflect.Proxy +import java.nio.file.FileSystemException +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.GroupPrincipal +import java.nio.file.attribute.PosixFileAttributes +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.PosixFilePermissions +import java.nio.file.attribute.UserPrincipal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private const val OWNER_WRITABLE = "rwxr-xr-x" +private const val GROUP_WRITABLE = "rwxrwxr-x" +private const val OTHERS_WRITABLE = "rwxr-xrwx" +private const val WORLD_WRITABLE = "rwxrwxrwx" +private const val APPIMAGE_FILE_NAME = "SimpleX.AppImage" +private const val XDG_MIME = "xdg-mime" + +class AppLinkSchemeTest { + private val windowsExe = "C:\\Program Files\\SimpleX\\SimpleX.exe" + private val appLinkCommandKey = "Software\\Classes\\simplexchat\\shell\\open\\command" + private val connectionCommandKey = "Software\\Classes\\simplex\\shell\\open\\command" + private val appLinkCommand = appLinkCommandKey to "" + private val connectionCommand = connectionCommandKey to "" + private val olderWindowsCommand = "\"C:\\Old\\SimpleX.exe\" \"%1\"" + private val appImagePath = "/home/user/$APPIMAGE_FILE_NAME" + + @Test + fun windowsCommandQuotesTheAppAndTheLink() { + assertEquals( + "\"C:\\Program Files\\SimpleX\\SimpleX.exe\" \"%1\"", + windowsOpenCommand(windowsExe) + ) + } + + @Test + fun windowsSchemeIsAUrlProtocolNamingItsProgId() { + assertEquals( + listOf( + RegistryValue(key = "Software\\Classes\\simplexchat", name = "", value = "URL:SimpleX Chat"), + RegistryValue(key = "Software\\Classes\\simplexchat", name = "URL Protocol", value = ""), + RegistryValue(key = "Software\\Classes\\simplexchat\\DefaultIcon", name = "", value = "\"$windowsExe\",0"), + RegistryValue(key = appLinkCommandKey, name = "", value = "\"$windowsExe\" \"%1\""), + RegistryValue(key = "Software\\chat.simplex.app\\Capabilities\\URLAssociations", name = "simplexchat", value = "SimpleX.simplexchat"), + ), + windowsSchemeValues("simplexchat", windowsExe), + "app link scheme" + ) + assertEquals( + listOf( + RegistryValue(key = "Software\\Classes\\simplex", name = "", value = "URL:SimpleX Chat"), + RegistryValue(key = "Software\\Classes\\simplex", name = "URL Protocol", value = ""), + RegistryValue(key = "Software\\Classes\\simplex\\DefaultIcon", name = "", value = "\"$windowsExe\",0"), + RegistryValue(key = connectionCommandKey, name = "", value = "\"$windowsExe\" \"%1\""), + RegistryValue(key = "Software\\chat.simplex.app\\Capabilities\\URLAssociations", name = "simplex", value = "SimpleX.simplex"), + ), + windowsSchemeValues("simplex", windowsExe), + "connection link scheme" + ) + } + + @Test + fun windowsProgIdOpensTheExe() { + assertEquals( + listOf( + RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat", name = "", value = "URL:SimpleX Chat"), + RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat\\DefaultIcon", name = "", value = "\"$windowsExe\",0"), + RegistryValue(key = "Software\\Classes\\SimpleX.simplexchat\\shell\\open\\command", name = "", value = "\"$windowsExe\" \"%1\""), + ), + windowsProgIdValues("simplexchat", windowsExe) + ) + } + + @Test + fun windowsApplicationIsListedInDefaultAppsAfterItsCapabilities() { + assertEquals( + listOf( + RegistryValue(key = "Software\\chat.simplex.app\\Capabilities", name = "ApplicationName", value = "SimpleX Chat"), + RegistryValue( + key = "Software\\chat.simplex.app\\Capabilities", + name = "ApplicationDescription", + value = "Private and secure open-source messenger - no user IDs (not even random numbers)" + ), + RegistryValue(key = "Software\\RegisteredApplications", name = "SimpleX Chat", value = "Software\\chat.simplex.app\\Capabilities"), + ), + windowsApplicationValues + ) + } + + @Test + fun windowsRegistrationWritesEveryValueWhenTheCommandsPointAtAMissingProgram() { + val shell = FakeWindowsShell(user = mutableMapOf(appLinkCommand to olderWindowsCommand, connectionCommand to olderWindowsCommand)) + registerWindowsSchemes(windowsExe, shell) + assertEquals(fullRegistration(), shell.writes) + assertTrue(shell.notified, "Windows is told that associations changed") + } + + @Test + fun windowsCommandProgramIsTheQuotedOrFirstArgument() { + assertEquals("C:\\Program Files\\Other\\Other.exe", windowsCommandProgram("\"C:\\Program Files\\Other\\Other.exe\" \"%1\""), "quoted") + assertEquals("C:\\Other\\Other.exe", windowsCommandProgram("C:\\Other\\Other.exe %1"), "unquoted") + assertEquals("C:\\Other\\Other.exe", windowsCommandProgram("C:\\Other\\Other.exe"), "no arguments") + } + + @Test + fun windowsRegistrationLeavesAFullRegistrationAlone() { + val shell = FakeWindowsShell(user = registered()) + registerWindowsSchemes(windowsExe, shell) + assertEquals(listOf(), shell.writes, "nothing is written again") + assertFalse(shell.notified, "an unchanged registration does not clear Explorer's caches") + } + + @Test + fun windowsRegistrationRepairsAValueMissingBesideAMatchingCommand() { + val shell = FakeWindowsShell(user = registered().apply { remove("Software\\Classes\\simplexchat" to "URL Protocol") }) + registerWindowsSchemes(windowsExe, shell) + assertEquals(windowsSchemeValues("simplexchat", windowsExe), shell.writes, "only the incomplete scheme is rewritten") + } + + @Test + fun windowsRegistrationAnnouncesARepairedApplicationListing() { + val shell = FakeWindowsShell(user = registered().apply { remove("Software\\RegisteredApplications" to "SimpleX Chat") }) + registerWindowsSchemes(windowsExe, shell) + assertEquals(windowsApplicationValues, shell.writes, "only the application listing is rewritten") + assertTrue(shell.notified, "Windows is told that associations changed") + } + + @Test + fun windowsRegistrationKeepsAnotherInstalledHandler() = withTempDir { dir -> + val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\"" + val shell = FakeWindowsShell(user = mutableMapOf(appLinkCommand to otherCommand, connectionCommand to otherCommand)) + registerWindowsSchemes(windowsExe, shell) + assertEquals( + windowsProgIdValues("simplexchat", windowsExe) + windowsProgIdValues("simplex", windowsExe), + shell.writes, + "only this app's own ProgIds are written; no scheme is taken and no application is listed without one" + ) + } + + @Test + fun windowsRegistrationKeepsAMachineWideHandler() = withTempDir { dir -> + val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\"" + val shell = FakeWindowsShell(machine = mutableMapOf(appLinkCommand to otherCommand)) + registerWindowsSchemes(windowsExe, shell) + assertTrue(shell.writes.none { it.key == appLinkCommandKey }, "a per-user key would hide the machine-wide handler") + assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the free scheme is taken") + } + + @Test + fun windowsRegistrationRepairsItsProgIdWhileAnotherProgramHoldsTheScheme() = withTempDir { dir -> + val otherCommand = "\"${Files.createFile(dir.resolve("Other.exe"))}\" \"%1\"" + val staleProgId = windowsProgIdValues("simplexchat", "C:\\Old\\SimpleX.exe").associate { (it.key to it.name) to it.value } + val shell = FakeWindowsShell(user = (staleProgId + (appLinkCommand to otherCommand)).toMutableMap()) + registerWindowsSchemes(windowsExe, shell) + assertEquals(windowsProgIdValues("simplexchat", windowsExe), shell.writes.take(3), "the ProgId points at this exe again") + } + + @Test + fun windowsRegistrationReportsTheProgramTheShellStarts() { + assertFalse(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = "C:\\Other\\Other.exe")), "another app picked in settings") + assertTrue(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = windowsExe.uppercase())), "paths differ only in case") + assertFalse(registerWindowsSchemes(windowsExe, FakeWindowsShell(user = registered(), handler = null)), "the shell has no answer") + } + + @Test + fun windowsRegistrationContinuesPastARegistryError() { + val shell = FakeWindowsShell(failingWrites = setOf("Software\\Classes\\simplexchat")) + registerWindowsSchemes(windowsExe, shell) + assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "the app link scheme could not be written") + assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the connection link scheme is still written") + assertTrue(windowsApplicationValues.all { it in shell.writes }, "the application is listed for the scheme that is ours") + } + + @Test + fun windowsRegistrationTakesNoSchemeWhoseHandlerCannotBeRead() { + val shell = FakeWindowsShell(failingReads = setOf(appLinkCommandKey, connectionCommandKey)) + registerWindowsSchemes(windowsExe, shell) + assertEquals( + windowsProgIdValues("simplexchat", windowsExe) + windowsProgIdValues("simplex", windowsExe), + shell.writes, + "an unreadable handler is left alone, and no application is listed without a scheme" + ) + } + + @Test + fun windowsRegistrationTakesNoSchemeWhoseMachineWideHandlerCannotBeRead() { + val shell = FakeWindowsShell(failingMachineReads = setOf(appLinkCommandKey)) + registerWindowsSchemes(windowsExe, shell) + assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "the app link scheme is left alone") + assertTrue(windowsSchemeValues("simplex", windowsExe).all { it in shell.writes }, "the readable scheme is taken") + } + + @Test + fun windowsRegistrationTakesNoSchemeWhoseProgIdCannotBeWritten() { + val shell = FakeWindowsShell(failingWrites = setOf("Software\\Classes\\SimpleX.simplexchat")) + registerWindowsSchemes(windowsExe, shell) + assertTrue(windowsSchemeValues("simplexchat", windowsExe).none { it in shell.writes }, "URLAssociations must not name a missing ProgId") + } + + @Test + fun windowsRegistrationKeepsAnotherInstalledCopysProgId() = withTempDir { dir -> + val otherCopy = Files.createFile(dir.resolve("SimpleX.exe")).toString() + val otherProgId = windowsProgIdValues("simplexchat", otherCopy).associate { (it.key to it.name) to it.value } + val shell = FakeWindowsShell(user = otherProgId.toMutableMap()) + registerWindowsSchemes(windowsExe, shell) + assertTrue(shell.writes.none { it.key.contains("simplexchat") }, "links picked for the other copy keep reaching it: neither its ProgId nor the scheme is taken") + } + + @Test + fun windowsRegistrationTreatsItsOwnProgramAsItsOwnHandler() = withTempDir { dir -> + val exe = Files.createFile(dir.resolve("SimpleX.exe")).toString() + val otherExe = Files.createFile(dir.resolve("Other.exe")).toString() + val shell = FakeWindowsShell( + user = mutableMapOf(appLinkCommand to "\"$exe\" %1"), + machine = mutableMapOf(appLinkCommand to "\"$otherExe\" \"%1\""), + ) + registerWindowsSchemes(exe, shell) + assertTrue(windowsSchemeValues("simplexchat", exe).all { it in shell.writes }, "a command for this exe is updated, and the hidden machine-wide one is ignored") + } + + private fun fullRegistration(): List = + windowsProgIdValues("simplexchat", windowsExe) + windowsSchemeValues("simplexchat", windowsExe) + + windowsProgIdValues("simplex", windowsExe) + windowsSchemeValues("simplex", windowsExe) + windowsApplicationValues + + private fun registered(): MutableMap, String> = + fullRegistration().associate { (it.key to it.name) to it.value }.toMutableMap() + + private inner class FakeWindowsShell( + private val user: MutableMap, String> = mutableMapOf(), + private val machine: Map, String> = mapOf(), + private val handler: String? = windowsExe, + private val failingReads: Set = setOf(), + private val failingMachineReads: Set = setOf(), + private val failingWrites: Set = setOf(), + ) : WindowsShell { + val writes = mutableListOf() + var notified = false + + override fun readUser(key: String, name: String): String? { + if (key in failingReads) throw AccessDenied() + return user[key to name] + } + + override fun readMachine(key: String, name: String): String? { + if (key in failingMachineReads) throw AccessDenied() + return machine[key to name] + } + + override fun writeUser(value: RegistryValue) { + if (value.key in failingWrites) throw AccessDenied() + writes += value + user[value.key to value.name] = value.value + } + + override fun linkHandler(scheme: String): String? = handler + + override fun notifyAssociationsChanged() { + notified = true + } + } + + // Win32Exception's public constructors format the message through kernel32, which only Windows has. + private class AccessDenied : Win32Exception(WinError.ERROR_ACCESS_DENIED, WinNT.HRESULT(WinError.ERROR_ACCESS_DENIED), "access denied") + + @Test + fun execArgumentOfAPlainPathIsLeftBare() { + assertEquals("/home/user/Downloads/simplex-desktop-x86_64.AppImage", desktopExecArgument("/home/user/Downloads/simplex-desktop-x86_64.AppImage")) + } + + @Test + fun execArgumentIsQuotedForEveryReservedCharacter() { + for (c in " \t\n\"'\\><~|&;$*?#()`") { + val arg = desktopExecArgument("/a${c}b") + assertTrue(arg.startsWith("\"") && arg.endsWith("\""), "reserved character ${c.code} must be quoted, got $arg") + } + } + + @Test + fun execArgumentKeepsSpacesInsideTheQuotes() { + assertEquals("\"/home/user/My Apps/simplex.AppImage\"", desktopExecArgument("/home/user/My Apps/simplex.AppImage")) + } + + @Test + fun execArgumentEscapesReservedCharacters() { + assertEquals("\"/a\\\\\"b\"", desktopExecArgument("/a\"b"), "double quote") + assertEquals("\"/a\\\\`b\"", desktopExecArgument("/a`b"), "backtick") + assertEquals("\"/a\\\\\$b\"", desktopExecArgument("/a\$b"), "dollar") + } + + @Test + fun execArgumentTurnsABackslashIntoFour() { + assertEquals("\"/a\\\\\\\\b\"", desktopExecArgument("/a\\b")) + } + + @Test + fun appImageEntryHandlesTheSchemeAndStaysOutOfMenus() { + assertEquals( + """ + |[Desktop Entry] + |Type=Application + |Name=SimpleX Chat + |NoDisplay=true + |Exec="/home/user/My Apps/simplex.AppImage" %u + |MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex; + |""".trimMargin(), + appImageDesktopEntry("/home/user/My Apps/simplex.AppImage") + ) + } + + @Test + fun appImageEntryIsRefusedForAPathItCannotRun() { + assertNull(appImageDesktopEntry("/a\nExec=evil"), "line feed") + assertNull(appImageDesktopEntry("/a\rb"), "carriage return") + assertNull(appImageDesktopEntry("/a%1.AppImage"), "percent") + } + + private val linux = DesktopPlatform.LINUX_X86_64 + private val linuxAppPath = "/opt/simplex/bin/simplex" + + @Test + fun unpackagedRunHasNoInstallation() { + assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(linux, null, mapOf()), "linux") + assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(DesktopPlatform.MAC_AARCH64, null, mapOf()), "macOS") + assertEquals(DesktopInstallation.Unpackaged, desktopInstallation(DesktopPlatform.WINDOWS_X86_64, null, mapOf()), "windows") + } + + @Test + fun packagedMacAndWindowsAreRecognised() { + assertEquals(DesktopInstallation.MacBundle, desktopInstallation(DesktopPlatform.MAC_AARCH64, "/Applications/SimpleX.app/Contents/MacOS/SimpleX", mapOf())) + assertEquals(DesktopInstallation.WindowsExe(windowsExe), desktopInstallation(DesktopPlatform.WINDOWS_X86_64, windowsExe, mapOf())) + } + + @Test + fun flatpakWinsWhenTheAppImageCheckWouldAlsoMatch() { + val env = mapOf("container" to "flatpak", "APPIMAGE" to "/home/user/a.AppImage", "APPDIR" to "/tmp/.mount_a") + assertEquals(DesktopInstallation.Flatpak, desktopInstallation(linux, "/tmp/.mount_a/usr/bin/simplex", env)) + } + + @Test + fun appImageIsRecognisedWhenTheLauncherRunsFromItsMount() { + val env = mapOf("APPIMAGE" to appImagePath, "APPDIR" to "/tmp/.mount_Simple") + assertEquals( + DesktopInstallation.AppImage(appImagePath), + desktopInstallation(linux, "/tmp/.mount_Simple/usr/bin/simplex", env) + ) + } + + @Test + fun appImageIsRecognisedWhenItsMountIsReachedThroughASymlink() = withTempDir { tmp -> + val mount = Files.createDirectories(tmp.resolve("real/.mount_Simple/usr/bin")) + val linkedTmp = try { + Files.createSymbolicLink(tmp.resolve("linked"), tmp.resolve("real")) + } catch (e: FileSystemException) { + assumeNoException("symbolic links cannot be created here", e) + return@withTempDir + } + val env = mapOf("APPIMAGE" to appImagePath, "APPDIR" to linkedTmp.resolve(".mount_Simple").toString()) + assertEquals( + DesktopInstallation.AppImage(appImagePath), + desktopInstallation(linux, mount.toRealPath().resolve("simplex").toString(), env) + ) + } + + @Test + fun inheritedAppImageVariablesDoNotMakeTheDebAnAppImage() { + val inherited = mapOf("APPIMAGE" to "/home/user/Terminal.AppImage", "APPDIR" to "/tmp/.mount_Termin") + assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, linuxAppPath, inherited), "another AppImage's mount") + assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, linuxAppPath, mapOf("APPIMAGE" to "/home/user/a.AppImage")), "no APPDIR") + val prefix = mapOf("APPIMAGE" to "/home/user/a.AppImage", "APPDIR" to "/tmp/.mount_ab") + assertEquals(DesktopInstallation.LinuxPackage, desktopInstallation(linux, "/tmp/.mount_abc/usr/bin/simplex", prefix), "mount path that only shares a prefix") + } + + @Test + fun fileOnlyItsOwnerCanWriteIsAccepted() { + assertTrue(onlyOwnerCanWrite(attributes(owner = "alice", group = "staff", OWNER_WRITABLE), user = "alice")) + } + + @Test + fun rootOwnedFileIsAccepted() { + assertTrue(onlyOwnerCanWrite(attributes(owner = "root", group = "root", OWNER_WRITABLE), user = "alice")) + } + + @Test + fun fileOfAnotherOwnerIsRefused() { + assertFalse(onlyOwnerCanWrite(attributes(owner = "bob", group = "staff", OWNER_WRITABLE), user = "alice")) + } + + @Test + fun worldWritableFileIsRefused() { + assertFalse(onlyOwnerCanWrite(attributes(owner = "alice", group = "alice", OTHERS_WRITABLE), user = "alice")) + } + + @Test + fun groupWritableFileIsRefusedForASharedGroup() { + assertFalse(onlyOwnerCanWrite(attributes(owner = "alice", group = "staff", GROUP_WRITABLE), user = "alice")) + } + + @Test + fun groupWritableFileIsAcceptedForTheUsersOwnGroup() { + assertTrue(onlyOwnerCanWrite(attributes(owner = "alice", group = "alice", GROUP_WRITABLE), user = "alice"), "owned by the user") + assertTrue(onlyOwnerCanWrite(attributes(owner = "root", group = "alice", GROUP_WRITABLE), user = "alice"), "owned by root") + assertFalse(onlyOwnerCanWrite(attributes(owner = "root", group = "root", GROUP_WRITABLE), user = "alice"), "root's own group") + } + + private fun attributes(owner: String, group: String, permissions: String): PosixFileAttributes = + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(PosixFileAttributes::class.java)) { _, method, _ -> + when (method.name) { + "owner" -> UserPrincipal { owner } + "group" -> GroupPrincipal { group } + "permissions" -> PosixFilePermissions.fromString(permissions) + else -> throw UnsupportedOperationException(method.name) + } + } as PosixFileAttributes + + @Test + fun appImageOnlyItsOwnerCanWriteIsAccepted() = withAppImage { + assertTrue(onlyOwnerCanReplace(it, Files.getOwner(it).name)) + } + + @Test + fun appImageInAWorldWritableDirectoryIsRefused() = withAppImage(directoryPermissions = WORLD_WRITABLE) { + assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name), "the directory must be checked as well as the file") + } + + @Test + fun appImageUnderAWorldWritableAncestorIsRefused() = withAppImage(ancestorPermissions = WORLD_WRITABLE) { + assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name), "every directory up to the root must be checked") + } + + @Test + fun worldWritableAppImageIsRefused() = withAppImage(filePermissions = OTHERS_WRITABLE) { + assertFalse(onlyOwnerCanReplace(it, Files.getOwner(it).name)) + } + + @Test + fun appImageOfAnotherOwnerIsRefused() = withAppImage { + val owner = Files.getOwner(it).name + assumeTrue("root is accepted as an owner", owner != "root") + assertFalse(onlyOwnerCanReplace(it, "$owner-other"), "a file owned by $owner checked for $owner-other") + } + + @Test + fun missingAppImageIsRefused() { + assumePosix() + assertFalse(onlyOwnerCanReplace(Path.of("/nonexistent-simplex-dir/SimpleX.AppImage"), "root")) + } + + // Built under the module's build directory, as the check walks up to the root and the system temp directory is open to everyone. + private fun withAppImage( + directoryPermissions: String = OWNER_WRITABLE, + filePermissions: String = OWNER_WRITABLE, + ancestorPermissions: String = OWNER_WRITABLE, + fileName: String = APPIMAGE_FILE_NAME, + check: (Path) -> Unit + ) { + assumePosix() + val base = Path.of("build").toAbsolutePath() + val user = System.getProperty("user.name") + assumeTrue( + "$base and every directory above it must be owned by $user or root and closed to others", + Files.isDirectory(base) && generateSequence(base) { it.parent }.all { + val attributes = Files.readAttributes(it, PosixFileAttributes::class.java) + attributes.owner().name in setOf(user, "root") && + PosixFilePermission.OTHERS_WRITE !in attributes.permissions() && + (PosixFilePermission.GROUP_WRITE !in attributes.permissions() || attributes.group().name == user) + } + ) + withTempDir(base) { tmp -> + val ancestor = Files.createDirectory(tmp.resolve("shared")) + val dir = Files.createDirectory(ancestor.resolve("apps")) + val appImage = Files.createFile(dir.resolve(fileName)) + Files.setPosixFilePermissions(appImage, PosixFilePermissions.fromString(filePermissions)) + Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString(directoryPermissions)) + Files.setPosixFilePermissions(ancestor, PosixFilePermissions.fromString(ancestorPermissions)) + check(appImage) + } + } + + private fun assumePosix() = + assumeTrue("needs POSIX permissions", "posix" in FileSystems.getDefault().supportedFileAttributeViews()) + + @Test + fun runProcessReturnsTheOutputOfASuccessfulRun() { + assumeShell() + assertEquals("x\n", runProcess(listOf("sh", "-c", "echo x"))) + } + + @Test + fun runProcessKeepsErrorOutputOutOfTheResult() { + assumeShell() + // more than a pipe holds, so error output left in a pipe would block the process until the timeout + assertEquals("x\n", runProcess(listOf("sh", "-c", "head -c 100000 /dev/zero >&2; echo x"))) + } + + @Test + fun runProcessGivesTheProcessAnEndOfInput() { + assumeShell() + assertEquals("x\n", runProcess(listOf("sh", "-c", "cat >/dev/null; echo x")), "a process reading its input must not wait for the timeout") + } + + @Test + fun runProcessReturnsNothingForAFailedRun() { + assumeShell() + assertNull(runProcess(listOf("sh", "-c", "echo x; exit 3"))) + } + + private fun queryDefault(mimeType: String) = listOf(XDG_MIME, "query", "default", mimeType) + private fun setDefault(mimeType: String) = listOf(XDG_MIME, "default", APPIMAGE_ENTRY_NAME, mimeType) + private val queryBothDefaults = listOf(queryDefault(APP_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE)) + private val setBothDefaults = listOf( + queryDefault(APP_LINK_MIME_TYPE), setDefault(APP_LINK_MIME_TYPE), queryDefault(APP_LINK_MIME_TYPE), + queryDefault(CONNECTION_LINK_MIME_TYPE), setDefault(CONNECTION_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE), + ) + + @Test + fun appImageRegistrationWritesTheEntryAndMakesItTheDefault() = withRegistration { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = null) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the entry became the default handler") + assertEquals(appImageDesktopEntry(appImage), File(applicationsDir, APPIMAGE_ENTRY_NAME).readText(), "the missing directory is created") + assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls) + } + + @Test + fun appImageRegistrationLeavesAnUnchangedEntryThatIsAlreadyTheDefault() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!) + val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run)) + assertEquals(queryBothDefaults, xdg.calls, "nothing is rewritten or set again") + } + + @Test + fun appImageRegistrationTakesTheDefaultFromAHandlerNoLongerInstalled() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!) + val xdg = FakeXdgMime(default = "other.desktop") + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the entry became the default handler again") + assertEquals(setBothDefaults, xdg.calls, "the entry is not rewritten, only made the default") + } + + @Test + fun appImageRegistrationSetsOnlyTheDefaultThatPointsElsewhere() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry(appImage)!!) + val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME, connectionDefault = "other.desktop") + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run)) + assertEquals( + listOf(queryDefault(APP_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE), setDefault(CONNECTION_LINK_MIME_TYPE), queryDefault(CONNECTION_LINK_MIME_TYPE)), + xdg.calls + ) + } + + @Test + fun appImageRegistrationRewritesTheEntryOfAMovedAppImage() = withRegistration { appImage, applicationsDir -> + applicationsDir.mkdirs() + File(applicationsDir, APPIMAGE_ENTRY_NAME).writeText(appImageDesktopEntry("/old/SimpleX.AppImage")!!) + val xdg = FakeXdgMime(default = APPIMAGE_ENTRY_NAME) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run)) + assertEquals(appImageDesktopEntry(appImage), File(applicationsDir, APPIMAGE_ENTRY_NAME).readText()) + assertEquals(listOf(updateDatabase(applicationsDir)) + queryBothDefaults, xdg.calls) + } + + @Test + fun appImageRegistrationFailsWhenAnotherHandlerKeepsTheAppLinkDefault() = withRegistration { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = "other.desktop", keptMimeTypes = setOf(APP_LINK_MIME_TYPE)) + assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "the default handler did not change") + assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls, "the connection link default is still set") + } + + @Test + fun appImageRegistrationSucceedsWhenAnotherHandlerKeepsOnlyTheConnectionDefault() = withRegistration { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = "other.desktop", keptMimeTypes = setOf(CONNECTION_LINK_MIME_TYPE)) + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "only simplexchat: decides the badge page") + assertEquals(listOf(updateDatabase(applicationsDir)) + setBothDefaults, xdg.calls) + } + + @Test + fun appImageRegistrationKeepsAnotherInstalledHandler() = withRegistration { appImage, applicationsDir -> + val systemDir = File(applicationsDir.parentFile, "system-applications").apply { mkdirs() } + File(systemDir, "other.desktop").writeText("[Desktop Entry]\n") + val xdg = FakeXdgMime(default = "other.desktop") + assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(systemDir), xdg::run), "links stay with the installed handler") + assertEquals(listOf(updateDatabase(applicationsDir)) + queryBothDefaults, xdg.calls, "the user's handler is not replaced") + } + + @Test + fun appImageRegistrationKeepsAnotherInstalledConnectionHandlerOnly() = withRegistration { appImage, applicationsDir -> + val systemDir = File(applicationsDir.parentFile, "system-applications").apply { mkdirs() } + File(systemDir, "other.desktop").writeText("[Desktop Entry]\n") + val xdg = FakeXdgMime(default = null, connectionDefault = "other.desktop") + assertTrue(registerAppImageScheme(appImage, applicationsDir, listOf(systemDir), xdg::run), "app links have no handler, so the entry takes them") + assertEquals( + listOf( + updateDatabase(applicationsDir), queryDefault(APP_LINK_MIME_TYPE), setDefault(APP_LINK_MIME_TYPE), queryDefault(APP_LINK_MIME_TYPE), + queryDefault(CONNECTION_LINK_MIME_TYPE), + ), + xdg.calls + ) + } + + @Test + fun appImageRegistrationIsRefusedForAPathTheEntryCannotRun() = + withRegistration(fileName = "Simple%X.AppImage") { appImage, applicationsDir -> + val xdg = FakeXdgMime(default = null) + assertFalse(registerAppImageScheme(appImage, applicationsDir, listOf(), xdg::run), "a path with % cannot be registered") + assertEquals(listOf(), xdg.calls, "nothing is run") + assertFalse(applicationsDir.exists(), "no entry is written") + } + + @Test + fun linuxPackageCountsAsRegisteredOnlyWhenItsEntryIsTheDefault() { + assertTrue(linuxPackageRegistered(FakeXdgMime(default = "simplex-simplex.desktop")::run), "the deb's own entry") + assertFalse(linuxPackageRegistered(FakeXdgMime(default = "other.desktop")::run), "another handler") + assertFalse(linuxPackageRegistered(FakeXdgMime(default = null)::run), "no handler") + assertFalse( + linuxPackageRegistered(FakeXdgMime(default = "other.desktop", connectionDefault = "simplex-simplex.desktop")::run), + "only the connection link handler" + ) + } + + private fun updateDatabase(applicationsDir: File) = listOf("update-desktop-database", applicationsDir.path) + + private class FakeXdgMime(default: String?, connectionDefault: String? = default, private val keptMimeTypes: Set = setOf()) { + private val defaults = mutableMapOf(APP_LINK_MIME_TYPE to default, CONNECTION_LINK_MIME_TYPE to connectionDefault) + val calls = mutableListOf>() + + fun run(command: List): String? { + calls += command + if (command.take(3) == listOf(XDG_MIME, "query", "default")) return defaults[command[3]]?.let { "$it\n" } + // xdg-mime sets the default only when given an entry and then a MIME type, the order it requires + if (command.size == 4 && command[1] == "default" && command[2].endsWith(".desktop") && command[3] !in keptMimeTypes) { + defaults[command[3]] = command[2] + } + return "" + } + } + + private fun withRegistration(fileName: String = APPIMAGE_FILE_NAME, test: (appImage: String, applicationsDir: File) -> Unit) = + withAppImage(fileName = fileName) { appImage -> + assumeTrue("the AppImage must be owned by user.name", Files.getOwner(appImage).name == System.getProperty("user.name")) + test(appImage.toString(), appImage.parent.parent.parent.resolve("share/applications").toFile()) + } + + private fun assumeShell() = + assumeTrue("needs sh", System.getenv("PATH")?.split(File.pathSeparator)?.any { Files.isExecutable(Path.of(it, "sh")) } == true) +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinksTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinksTest.kt new file mode 100644 index 0000000000..59c3c8c667 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AppLinksTest.kt @@ -0,0 +1,71 @@ +package chat.simplex.app + +import chat.simplex.common.MAX_LINK_BYTES +import chat.simplex.common.linkFromArgs +import chat.simplex.common.isAcceptedLink +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AppLinksTest { + @Test + fun acceptsTheOnlyArgumentWhenItIsAnAppLink() { + assertEquals(BADGE_LINK, linkFromArgs(arrayOf(BADGE_LINK))) + } + + @Test + fun acceptsTheSchemeInAnyCase() { + val link = "SimplexChat:/badge/code/SB0000000000000000000" + assertEquals(link, linkFromArgs(arrayOf(link))) + } + + @Test + fun ignoresNoArguments() { + assertNull(linkFromArgs(arrayOf())) + } + + @Test + fun acceptsAConnectionLink() { + assertEquals(SHORT_CONNECTION_LINK, linkFromArgs(arrayOf(SHORT_CONNECTION_LINK)), "short link") + val oneTimeLink = oneTimeLinkWithKemKey() + assertEquals(oneTimeLink, linkFromArgs(arrayOf(oneTimeLink)), "one-time link of ${oneTimeLink.length} bytes with its post-quantum key") + val upperCase = "SimpleX:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" + assertEquals(upperCase, linkFromArgs(arrayOf(upperCase)), "scheme in another case") + } + + @Test + fun ignoresWebAndOtherLinks() { + assertNull(linkFromArgs(arrayOf("https://simplex.chat/contact#/?v=2-7")), "web link") + assertNull(linkFromArgs(arrayOf("simplexfile:/x")), "another scheme sharing the prefix") + assertNull(linkFromArgs(arrayOf("/home/user/simplex:/a")), "path") + } + + @Test + fun ignoresALinkThatIsNotTheOnlyArgument() { + // a URL split by quotes in a handler command arrives as several arguments + assertNull(linkFromArgs(arrayOf("--flag", BADGE_LINK)), "flag before link") + assertNull(linkFromArgs(arrayOf(BADGE_LINK, "extra")), "argument after link") + } + + @Test + fun ignoresAnEmptyArgument() { + assertNull(linkFromArgs(arrayOf(""))) + } + + @Test + fun lengthBoundIsInclusive() { + val atLimit = badgeLinkOfBytes(MAX_LINK_BYTES) + val overLimit = atLimit + "A" + assertTrue(isAcceptedLink(atLimit), "link of exactly $MAX_LINK_BYTES bytes") + assertFalse(isAcceptedLink(overLimit), "link of ${overLimit.length} bytes") + assertNull(linkFromArgs(arrayOf(overLimit))) + } + + @Test + fun lengthBoundCountsUtf8Bytes() { + // The link has 4213 characters but 8413 bytes, so a bound counted in characters would accept it. + assertNull(linkFromArgs(arrayOf("simplexchat:/" + "é".repeat(4200)))) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/EmojiPickerDataTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/EmojiPickerDataTest.kt new file mode 100644 index 0000000000..c5860ed9de --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/EmojiPickerDataTest.kt @@ -0,0 +1,55 @@ +package chat.simplex.app + +import chat.simplex.common.views.chat.* +import kotlin.test.* + +// use this command for testing: +// ./gradlew desktopTest +class EmojiPickerDataTest { + private val skinToneModifiers = 0x1F3FB..0x1F3FF + + @Test + fun testBundledDataHasAllCategoriesInOrder() { + assertEquals(EmojiCategory.entries.toList(), emojiSections.map { it.category }) + emojiSections.forEach { assertTrue(it.emojis.isNotEmpty(), "empty category ${it.category}") } + assertEquals(1870, emojiSections.sumOf { it.emojis.size }) + } + + @Test + fun testBundledDataEntries() { + val all = emojiSections.flatMap { it.emojis } + assertEquals(all.size, all.map { it.emoji }.toSet().size, "duplicate emoji") + all.forEach { + assertTrue(it.emoji.isNotEmpty() && it.name.isNotBlank(), "bad entry $it") + assertFalse(it.emoji.codePoints().anyMatch { cp -> cp in skinToneModifiers }, "skin tone variant ${it.name}") + } + assertEquals(EmojiData("😀", "grinning face"), emojiSections.first().emojis.first()) + assertEquals(EmojiData("🏴󠁧󠁢󠁷󠁬󠁳󠁿", "flag: Wales"), emojiSections.last().emojis.last()) + } + + @Test + fun testParseEmojiData() { + val sections = parseEmojiData(sequenceOf("@Smileys & Emotion", "😀\tgrinning face", "", "@Flags", "🏁\tchequered flag", "#️⃣\tkeycap: #")) + assertEquals( + listOf( + EmojiSection(EmojiCategory.Smileys, listOf(EmojiData("😀", "grinning face"))), + EmojiSection(EmojiCategory.Flags, listOf(EmojiData("🏁", "chequered flag"), EmojiData("#️⃣", "keycap: #"))), + ), + sections + ) + } + + @Test + fun testParseUnknownGroupFails() { + assertFailsWith { parseEmojiData(sequenceOf("@Component", "🏻\tlight skin tone")) } + } + + @Test + fun testSearchEmoji() { + assertEquals(listOf("❤️"), searchEmoji(emojiSections, "red heart").map { it.emoji }) + assertEquals(searchEmoji(emojiSections, "red heart"), searchEmoji(emojiSections, " RED Heart ")) + val flags = searchEmoji(emojiSections, "flag: ") + assertTrue(flags.size > 250, "found ${flags.size} flags") + assertTrue(searchEmoji(emojiSections, "no such emoji name").isEmpty()) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt index 1b495c1774..0bfa16b4d3 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt @@ -1,14 +1,48 @@ package chat.simplex.app +import chat.simplex.common.FILE_TIME_TOLERANCE +import chat.simplex.common.MAX_LINK_BYTES +import chat.simplex.common.SHOW_FILE +import chat.simplex.common.SHOW_TMP_SUFFIX +import chat.simplex.common.ShowSignal +import chat.simplex.common.TAKEN_SHOW_FILE +import chat.simplex.common.deleteStaleSignalFiles +import chat.simplex.common.signalRunningInstance +import chat.simplex.common.startDespiteRunningInstance +import chat.simplex.common.takeSignal +import chat.simplex.common.watchShowSignals +import org.junit.Assume.assumeFalse +import org.junit.Assume.assumeTrue import java.nio.channels.FileChannel import java.nio.channels.OverlappingFileLockException +import java.nio.file.FileSystemException import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption.CREATE import java.nio.file.StandardOpenOption.READ import java.nio.file.StandardOpenOption.WRITE +import java.nio.file.StandardWatchEventKinds +import java.nio.file.attribute.FileTime +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFilePermission +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// generous for a loaded build machine; the watcher normally delivers within milliseconds +private const val SIGNAL_WAIT_SECONDS = 15L +// short, as the running instance in these tests never answers within the wait +private const val RUNNING_INSTANCE_WAIT_MILLIS = 300L class SingleInstanceTest { @Test @@ -43,14 +77,245 @@ class SingleInstanceTest { second.close() } - private fun withTempDir(block: (java.nio.file.Path) -> Unit) { - val tmp = Files.createTempDirectory("simplex-singleinstance-test") - try { - block(tmp) - } finally { - Files.walk(tmp).sorted(Comparator.reverseOrder()).forEach { - try { Files.delete(it) } catch (_: java.io.IOException) {} - } + @Test + fun secondProcessExitsWhenTheRunningInstanceTakesTheSignal() = withTempDir { dir -> + val taken = LinkedBlockingQueue() + val runningInstance = thread { + while (!Files.exists(dir.resolve(SHOW_FILE))) Thread.sleep(5) + takeSignal(dir)?.let(taken::add) + } + val start = startDespiteRunningInstance(dir, BADGE_LINK, TimeUnit.SECONDS.toMillis(SIGNAL_WAIT_SECONDS)) { + error("a responsive instance must not raise the alert") + } + runningInstance.join() + assertFalse(start, "the second process must exit") + assertEquals(BADGE_LINK, taken.poll()?.link, "the running instance opens the link") + } + + @Test + fun secondProcessExitsWhenTheUserDeclinesToStart() = withTempDir { dir -> + assertFalse(startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { false }) + assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the signal stays for the running instance to take once it recovers") + } + + @Test + fun secondProcessStartsAndWithdrawsTheSignalWhenTheUserStartsAnyway() = withTempDir { dir -> + assertTrue(startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { true }) + assertEquals(listOf(), fileNames(dir), "the withdrawn signal must not open the link in the running instance too") + } + + @Test + fun secondProcessExitsWhenTheRunningInstanceRecoversDuringTheAlert() = withTempDir { dir -> + var opened: String? = null + val start = startDespiteRunningInstance(dir, BADGE_LINK, RUNNING_INSTANCE_WAIT_MILLIS) { + opened = takeSignal(dir)?.link + true + } + assertEquals(BADGE_LINK, opened, "the recovered instance opened the link") + assertFalse(start, "starting anyway would open the same link a second time") + } + + @Test + fun signalCarriesTheLinkAndIsTakenOnce() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "only the signal may exist after signalling") + assertEquals(BADGE_LINK, takeSignal(dir)?.link) + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + assertNull(takeSignal(dir), "a signal already taken must not be taken again") + } + + @Test + fun signalCarriesAOneTimeLinkWithItsPostQuantumKey() = withTempDir { dir -> + val link = oneTimeLinkWithKemKey() + signalRunningInstance(dir, link) + assertEquals(link, takeSignal(dir)?.link, "a connection link of ${link.length} bytes") + } + + @Test + fun signalWithoutLinkIsTakenWithNoLink() = withTempDir { dir -> + signalRunningInstance(dir, null) + val signal = takeSignal(dir) + assertNotNull(signal, "an empty signal must still be taken") + assertNull(signal.link, "an empty signal carries no link") + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + assertNull(takeSignal(dir), "a signal already taken must not be taken again") + } + + @Test + fun laterSignalReplacesAnUntakenOne() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + signalRunningInstance(dir, OTHER_BADGE_LINK) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "the later signal must replace the earlier one") + assertEquals(OTHER_BADGE_LINK, takeSignal(dir)?.link) + } + + @Test + fun signalIsReadableOnlyByItsOwner() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + val view = Files.getFileAttributeView(dir.resolve(SHOW_FILE), PosixFileAttributeView::class.java) + assumeTrue("POSIX file permissions are not supported here", view != null) + assertEquals( + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + view.readAttributes().permissions() + ) + } + + @Test + fun takeSignalReplacesATakenFileLeftByACrash() = withTempDir { dir -> + Files.writeString(dir.resolve(TAKEN_SHOW_FILE), OTHER_BADGE_LINK) + signalRunningInstance(dir, BADGE_LINK) + assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the new signal, not the leftover, must be read") + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + } + + @Test + fun takeSignalRetriesAMoveThatFailsOnce() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + var attempts = 0 + val signal = takeSignal(dir) { from, to -> + attempts++ + if (attempts == 1) throw FileSystemException(from.toString(), null, "held open by another program") + Files.move(from, to, StandardCopyOption.ATOMIC_MOVE) + } + assertEquals(2, attempts, "one failed attempt and one retry") + assertEquals(BADGE_LINK, signal?.link, "the retry must still deliver the link") + assertEquals(listOf(), fileNames(dir), "taking the signal must leave no file behind") + } + + @Test + fun takeSignalLeavesTheSignalWhenTheRetryFailsToo() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + var attempts = 0 + val signal = takeSignal(dir) { from, _ -> + attempts++ + throw FileSystemException(from.toString(), null, "held open by another program") + } + assertEquals(2, attempts, "one failed attempt and one retry") + assertNotNull(signal, "the window still comes forward") + assertNull(signal.link, "a signal that cannot be read carries no link") + assertEquals(BADGE_LINK, takeSignal(dir)?.link, "the signal stays for the next attempt") + } + + @Test + fun takeSignalAcceptsALinkOfExactlyTheBound() = withTempDir { dir -> + val atBound = badgeLinkOfBytes(MAX_LINK_BYTES) + signalRunningInstance(dir, atBound) + assertEquals(atBound, takeSignal(dir)?.link, "a link of exactly $MAX_LINK_BYTES bytes") + } + + @Test + fun takeSignalDropsContentThatIsNotAnAcceptedLink() = withTempDir { dir -> + val show = dir.resolve(SHOW_FILE) + fun assertTakenWithoutLink(case: String) { + val signal = takeSignal(dir) + assertNotNull(signal, "$case: rejected content is still a signal, so the window comes forward") + assertNull(signal.link, case) + } + Files.writeString(show, "https://simplex.chat/contact#/?v=2-7") + assertTakenWithoutLink("web link") + assertEquals(listOf(), fileNames(dir), "a rejected signal must still be removed") + + Files.writeString(show, badgeLinkOfBytes(MAX_LINK_BYTES + 1)) + assertTakenWithoutLink("link over the length bound") + + // 4214 characters but 8414 bytes; a read cut at the bound would end on a whole character and decode to an accepted link + Files.writeString(show, "simplexchat:/x" + "é".repeat(4200)) + assertTakenWithoutLink("multi-byte link over the byte bound") + assertEquals(listOf(), fileNames(dir), "a rejected signal must still be removed") + } + + @Test + fun deleteStaleSignalFilesRemovesOldTempAndTakenFilesOnly() = withTempDir { dir -> + val lockAttemptTime = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val old = FileTime.from(lockAttemptTime.minusSeconds(3600)) + Files.setLastModifiedTime(Files.createFile(dir.resolve("simplex.started")), old) + Files.setLastModifiedTime(Files.createFile(dir.resolve("simplex_v1_chat.db")), old) + Files.setLastModifiedTime(Files.createFile(dir.resolve("other.tmp")), old) + Files.setLastModifiedTime(Files.createFile(dir.resolve("${SHOW_FILE}123$SHOW_TMP_SUFFIX")), old) + Files.createFile(dir.resolve("${SHOW_FILE}456$SHOW_TMP_SUFFIX")) + Files.createFile(dir.resolve(TAKEN_SHOW_FILE)) + Files.createFile(dir.resolve(SHOW_FILE)) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals( + listOf("other.tmp", SHOW_FILE, "${SHOW_FILE}456$SHOW_TMP_SUFFIX", "simplex.started", "simplex_v1_chat.db"), + fileNames(dir), + "an old temp file and a taken file must go; a fresh temp file is a signal being written, and the lock and database stay" + ) + } + + @Test + fun deleteStaleSignalFilesKeepsASignalWithinTheToleranceAndDropsAnOlderOne() = withTempDir { dir -> + val lockAttemptTime = Instant.now().truncatedTo(ChronoUnit.SECONDS) + val show = dir.resolve(SHOW_FILE) + signalRunningInstance(dir, BADGE_LINK) + Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minus(FILE_TIME_TOLERANCE))) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "a signal exactly at the tolerance must be kept") + + Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minusSeconds(1))) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals(listOf(SHOW_FILE), fileNames(dir), "a signal one second behind the lock attempt must be kept") + + Files.setLastModifiedTime(show, FileTime.from(lockAttemptTime.minus(FILE_TIME_TOLERANCE).minusSeconds(1))) + deleteStaleSignalFiles(dir, FileTime.from(lockAttemptTime)) + assertEquals(listOf(), fileNames(dir), "a signal older than the tolerance must be deleted") + } + + @Test + fun watcherTakesASignalWrittenBeforeItsWatch() = withTempDir { dir -> + signalRunningInstance(dir, BADGE_LINK) + watch(dir) { signals -> + assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "a signal written before the watch raised no event") } } + + @Test + fun watcherTakesEverySignalArrivingWhileItWatches() = withTempDir { dir -> + assumeWatchServiceReportsEachCreation(dir) + watch(dir) { signals -> + // Each signal is written after the previous one arrived, so later ones can come only from watch events. + signalRunningInstance(dir, BADGE_LINK) + assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "first signal") + signalRunningInstance(dir, OTHER_BADGE_LINK) + assertEquals(OTHER_BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "second signal, seen as a creation") + signalRunningInstance(dir, BADGE_LINK) + assertEquals(BADGE_LINK, signals.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.link, "third signal, after the watch key was reset") + } + } + + @Test + fun signalIsWrittenThroughATempFileBesideIt() = withTempDir { dir -> + // a temp file elsewhere may sit on another file system, where the atomic move fails and the link is dropped + assumeWatchServiceReportsEachCreation(dir) + val created = dir.fileSystem.newWatchService().use { ws -> + dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE) + signalRunningInstance(dir, BADGE_LINK) + ws.poll(SIGNAL_WAIT_SECONDS, TimeUnit.SECONDS)?.pollEvents()?.map { it.context().toString() }.orEmpty() + } + assertTrue( + created.any { it != TAKEN_SHOW_FILE && it.startsWith(SHOW_FILE) && it.endsWith(SHOW_TMP_SUFFIX) }, + "a temp file must be created in the signal directory, created: $created" + ) + } + + // macOS polls the directory and keeps a taken name cached, so it misses short-lived files and reports a reused name as a modification + private fun assumeWatchServiceReportsEachCreation(dir: Path) = + assumeFalse("needs a watch service that reports each creation", dir.fileSystem.newWatchService().use { it.javaClass.simpleName == "PollingWatchService" }) + + private fun watch(dir: Path, test: (signals: LinkedBlockingQueue) -> Unit) { + val signals = LinkedBlockingQueue() + val watcherThread = dir.fileSystem.newWatchService().use { ws -> + dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE) + thread(isDaemon = true) { watchShowSignals(ws, dir) { signals.put(it) } }.also { + test(signals) + // a second, duplicate delivery would arrive within this wait + assertNull(signals.poll(1, TimeUnit.SECONDS), "each signal is taken once") + } + } + watcherThread.join(TimeUnit.SECONDS.toMillis(SIGNAL_WAIT_SECONDS)) + assertFalse(watcherThread.isAlive, "closing the watch service ends the watcher") + } + + private fun fileNames(dir: Path): List = + Files.list(dir).use { files -> files.map { it.fileName.toString() }.sorted().toList() } } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TempDir.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TempDir.kt new file mode 100644 index 0000000000..95b65da588 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TempDir.kt @@ -0,0 +1,20 @@ +package chat.simplex.app + +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path + +private const val TEMP_DIR_PREFIX = "simplex-desktop-test" + +internal fun withTempDir(parent: Path? = null, block: (Path) -> Unit) { + val tmp = if (parent == null) Files.createTempDirectory(TEMP_DIR_PREFIX) else Files.createTempDirectory(parent, TEMP_DIR_PREFIX) + try { + block(tmp) + } finally { + Files.walk(tmp).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { + try { Files.delete(it) } catch (_: IOException) {} + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/WindowActivationTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/WindowActivationTest.kt new file mode 100644 index 0000000000..76dd2b46fb --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/WindowActivationTest.kt @@ -0,0 +1,86 @@ +package chat.simplex.app + +import chat.simplex.common.activationEvent +import chat.simplex.common.sendActivation +import com.sun.jna.NativeLong +import com.sun.jna.platform.unix.X11 +import org.junit.Assume.assumeNoException +import java.lang.reflect.Proxy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame + +private const val WINDOW_ID = 42L +private const val ATOM_ID = 7L + +class WindowActivationTest { + @Test + fun activationEventAsksTheWindowManagerToActivateTheWindowAsAPager() { + assumeLibX11() + val atom = X11.Atom(ATOM_ID) + val event = activationEvent(X11.Window(WINDOW_ID), atom) + val message = event.xclient + assertEquals(WINDOW_ID, message.window.toLong(), "target window") + assertEquals(atom, message.message_type, "_NET_ACTIVE_WINDOW atom") + assertEquals(32, message.format, "data format in bits") + // JNA writes only the selected member of a union, so the bytes XSendEvent receives are checked too + event.write() + assertEquals(X11.ClientMessage, event.pointer.getInt(0), "event type in native memory") + assertEquals(2L, message.data.pointer.getNativeLong(0).toLong(), "source indication in native memory: a pager, which focus stealing prevention lets through") + assertEquals(0L, message.data.pointer.getNativeLong(NativeLong.SIZE.toLong()).toLong(), "timestamp in native memory: CurrentTime") + } + + @Test + fun activationIsSentToTheRootWindowAndTheDisplayIsClosed() { + assumeLibX11() + val x11 = FakeX11() + sendActivation(x11.proxy, WINDOW_ID) + assertEquals(listOf("XOpenDisplay", "XInternAtom", "XDefaultRootWindow", "XSendEvent", "XFlush", "XCloseDisplay"), x11.calls) + assertNull(x11.displayName, "the display named by DISPLAY") + assertEquals("_NET_ACTIVE_WINDOW", x11.atomName, "atom of the request") + val send = x11.sendArgs + assertSame(x11.display, send[0], "display") + assertEquals(x11.root, send[1], "the request goes to the root window, where the window manager listens") + assertEquals(NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong()), send[3], "event mask the window manager selects") + val event = (send[4] as X11.XEvent).xclient + assertEquals(WINDOW_ID, event.window.toLong(), "window to activate") + assertEquals(x11.atom, event.message_type, "the interned atom is the request type") + } + + @Test + fun displayIsClosedWhenSendingFails() { + assumeLibX11() + val x11 = FakeX11(sendFails = true) + assertFailsWith { sendActivation(x11.proxy, WINDOW_ID) } + assertEquals("XCloseDisplay", x11.calls.last(), "the display must be closed after a failure") + } + + private class FakeX11(private val sendFails: Boolean = false) { + val display = X11.Display() + val root = X11.Window(1) + val atom = X11.Atom(ATOM_ID) + val calls = mutableListOf() + var displayName: Any? = null + var atomName: Any? = null + var sendArgs: Array = emptyArray() + + val proxy = Proxy.newProxyInstance(X11::class.java.classLoader, arrayOf(X11::class.java)) { _, method, args -> + calls += method.name + when (method.name) { + "XOpenDisplay" -> { displayName = args[0]; display } + "XInternAtom" -> { atomName = args[1]; atom } + "XDefaultRootWindow" -> root + "XSendEvent" -> { sendArgs = args.copyOf(); if (sendFails) throw IllegalStateException("send failed"); 1 } + "XFlush", "XCloseDisplay" -> 0 + else -> throw UnsupportedOperationException(method.name) + } + } as X11 + } + + // JNA loads libX11 to lay out any X11 structure, and only X11 desktops have it + private fun assumeLibX11() { + try { X11.INSTANCE } catch (e: LinkageError) { assumeNoException("libX11 is not available", e) } + } +} diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 7c7455c7f3..caaa1ba272 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -85,6 +85,18 @@ compose { extraKeysRawXml = """ NSMicrophoneUsageDescription SimpleX needs microphone access to record voice messages + CFBundleURLTypes + + + CFBundleURLName + $bundleID + CFBundleURLSchemes + + simplexchat + simplex + + + """ } val identity = rootProject.extra["desktop.mac.signing.identity"] as String? diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index e407448d1b..46640b3dbb 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -9,19 +9,27 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.* import chat.simplex.common.acquireSingleInstance +import chat.simplex.common.installOpenUriHandler +import chat.simplex.common.linkFromArgs import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.size import chat.simplex.common.platform.* import chat.simplex.common.platform.DesktopPlatform import chat.simplex.common.showApp +import chat.simplex.common.singleInstanceLock +import chat.simplex.common.startShowFileWatcher import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import kotlinx.coroutines.* import java.io.File -fun main() { +fun main(args: Array) { try { - if (!acquireSingleInstance()) return + val link = linkFromArgs(args) + if (!acquireSingleInstance(link)) return + link?.let { chatModel.appOpenUrl.value = null to it } + // started after the launch link is stored, so a link it forwards later is not overwritten + if (singleInstanceLock) startShowFileWatcher() // Clean shared temp dirs only in the owning instance (not in a Files.desktop val // initializer, which a transient second instance would also run). Early: before settings writes. preferencesTmpDir.deleteRecursively() @@ -31,13 +39,14 @@ fun main() { runMigrations() setupUpdateChecker() initApp() + registerLinkSchemes() tmpDir.deleteRecursively() tmpDir.mkdir() // Only the owning instance cleans tmpDir on exit (see preferencesTmpDir above). tmpDir.deleteOnExit() - // showApp is inside the try: its first statements (SystemTray probe, Compose setup) are the - // process's first AWT init, which is itself a known startup failure cause (#4146). Crashes - // after the window appears are handled by the WindowExceptionHandler in showApp instead. + // installOpenUriHandler and showApp stay inside the try: whichever touches AWT first makes the process's + // first AWT init, a known startup failure cause (#4146); later crashes go to showApp's WindowExceptionHandler. + if (desktopPlatform.isMac()) installOpenUriHandler() return showApp() } catch (e: Throwable) { showStartupError(e) // the jpackage launcher otherwise hides the error behind "Failed to launch JVM" (#4146) diff --git a/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop b/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop index 79281a4dc8..68844ed5e6 100644 --- a/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop +++ b/apps/multiplatform/desktop/src/jvmMain/resources/distribute/SimpleX.desktop @@ -2,6 +2,7 @@ Type=Application Name=SimpleX Chat Comment=Private and secure open-source messenger - no user IDs (not even random numbers) -Exec=/opt/simplex/bin/simplex +Exec=/opt/simplex/bin/simplex %u Icon=/opt/simplex/lib/simplex Categories=Network;Chat; +MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex; diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index d5375046e0..069a1a20b8 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,14 +24,14 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.1-beta.5 -android.version_code=383 +android.version_name=7.1-beta.6 +android.version_code=386 -desktop.version_name=7.1-beta.5 -desktop.version_code=165 +desktop.version_name=7.1-beta.6 +desktop.version_code=167 kotlin.version=2.1.20 -gradle.plugin.version=8.7.0 +gradle.plugin.version=8.10.1 compose.version=1.8.2 # Choose sqlite or postgres backend diff --git a/apps/multiplatform/product/README.md b/apps/multiplatform/product/README.md index 173def8ae7..3dff974366 100644 --- a/apps/multiplatform/product/README.md +++ b/apps/multiplatform/product/README.md @@ -277,6 +277,7 @@ SettingsView -> CallSettings .............. (call preferences) -> VersionInfoView ........... (about/version) -> DeveloperView ............. (developer options) + -> StorageView ............. (storage breakdown) -> HelpView .................. (help & support) UserPicker @@ -306,7 +307,7 @@ UserPicker | | OR (when no chat selected): | GroupProfileView | | UserPicker (overlay) | "No selected chat" | AddGroupMembersView | | - profile switcher | | MemberAdmission | -| - quick settings | OR (when modal open): | VerifyCodeView | +| - quick settings | Over them (when modal open): | VerifyCodeView | | | ModalManager.center content | SettingsView subtabs | | ModalManager.start | (settings, new chat, etc.) | | | - secondary modals | | ModalManager.end | @@ -315,7 +316,7 @@ UserPicker ModalManager Placement (Desktop): - ModalManager.start -> left panel overlay (settings subviews) - - ModalManager.center -> center panel (replaces chat, used when chatId is null) + - ModalManager.center -> center panel (drawn over the chat or the placeholder) - ModalManager.end -> right panel (detail/info views) - ModalManager.fullscreen -> full window overlay (onboarding, auth, call) diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index d29ac5c2e5..aa739ef636 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -22,7 +22,7 @@ This document provides a structured mapping between product-level concepts, thei | PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt`, `GetStakeBanner.kt` | `Controller.hs` (`APIGetChats`) | | PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` | | PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) | -| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) | +| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt`, `ComposeEmojiPicker.kt` | `Controller.hs` (`APISendMessages`) | | PC5 | Message Reactions | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/ChatItemView.kt` (ChatItemReactions composable) | `Controller.hs` (`APIChatItemReaction`) | | PC6 | Message Editing | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `ChatItemInfoView.kt` | `Controller.hs` (`APIUpdateChatItem`) | | PC7 | Message Deletion | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/MarkedDeletedItemView.kt`, `DeletedItemView.kt` | `Controller.hs` (`APIDeleteChatItem`) | @@ -101,6 +101,7 @@ Key files that exist only on one platform, grouped by concern. | `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` | JVM entry point, Haskell/VLC init, PlatformInterface setup | | `common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Compose Desktop window creation and lifecycle | | `common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt` | Window position/size persistence | +| `common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt` | Deep link handling from launch arguments and macOS | | `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt` | In-app update checker | | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt` | VLC-based video detection | | `common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` | VLC video player implementation | diff --git a/apps/multiplatform/product/flows/calling.md b/apps/multiplatform/product/flows/calling.md index fae7f42031..d29c1d4478 100644 --- a/apps/multiplatform/product/flows/calling.md +++ b/apps/multiplatform/product/flows/calling.md @@ -99,6 +99,7 @@ Both platforms share a common signaling flow through the Haskell core API. ``` Outgoing: WaitCapabilities -> InvitationSent -> OfferSent -> AnswerReceived -> Negotiated -> Connected -> Ended Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ended +Reconnection: Connected -> Reconnecting -> Connected | Ended ``` | State | Description | @@ -111,8 +112,17 @@ Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ende | `AnswerReceived` | Caller received SDP answer | | `Negotiated` | ICE negotiation complete | | `Connected` | Media flowing | +| `Reconnecting` | Connection lost, waiting for ICE restart | | `Ended` | Call terminated | +### 3.1 Reconnection + +1. A connected call loses its WebRTC connection, e.g. when the device switches between Wi-Fi and mobile network. +2. The WebRTC engine reports `"reconnecting"` connection state; the app shows "reconnecting…" and plays the connecting sound. This state is not reported to the core, and `connectedAt` is kept, so call duration continues. +3. The callee's WebRTC engine (the party that sent the initial offer) restarts ICE every 10 seconds, sending the new offer in `WCallResponse.Ice` with `session` via `apiSendCallExtraInfo`. The caller answers the same way. +4. When the connection is restored, the call returns to `Connected` and the sound stops. +5. If the connection is not restored in 30 seconds, the call ends as disconnected. Clients without ICE restart support ignore the restart offer, so the call ends in this case. + --- ## 4. Ending a Call @@ -184,7 +194,7 @@ Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ende | `apiRejectCall(rh, contact)` | Reject incoming call | | `apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` | Send SDP offer | | `apiSendCallAnswer(rh, contact, rtcSession, rtcIceCandidates)` | Send SDP answer | -| `apiSendCallExtraInfo(rh, contact, rtcIceCandidates)` | Send additional ICE candidates | +| `apiSendCallExtraInfo(rh, contact, rtcSession, rtcIceCandidates)` | Send additional ICE candidates, or ICE restart offer/answer | | `apiEndCall(rh, contact)` | End active call | | `apiCallStatus(rh, contact, status)` | Report WebRTC connection status | diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index aae24bdca8..7e8f841775 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -13,6 +13,8 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des 5. [Documentation: Haskell Store Layer Not Fully Specified](#gap-05-documentation-haskell-store-layer-not-fully-specified) 6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented) 7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented) +8. [Desktop: App Link Registration](#gap-08-desktop-app-link-registration) +9. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-09-desktop-emoji-picker-without-skin-tones-and-localized-search) --- @@ -289,3 +291,61 @@ This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self - GAP-03 (Database Passphrase Not Enforced) is compounded by this gap on Desktop. - The `testCrypto()` function referenced in `AppCommon.desktop.kt:39` is commented out with a `// LALAL` marker, suggesting crypto testing was planned but never completed. + +--- + +## GAP-08: Desktop: App Link Registration + +**Severity:** Low +**Category:** Platform +**Platform:** Desktop only + +### Description + +Desktop registers the `simplexchat:` scheme so the badge page can return to the app, and the `simplex:` scheme so connection links open in it. The registration has known limits: + +- Uninstalling or deleting the app leaves the per-user registration behind: the HKCU keys on Windows (the scheme keys, the ProgIds, `Capabilities` and the `RegisteredApplications` entry) and the hidden AppImage desktop entry on Linux. A later link then points at a missing program. On Windows, if the app was installed into a folder other users can write to (for example a custom folder at the root of `C:\`), another user could put a program at that path and have it run on the next link; the default Program Files install is not affected. This risk is accepted. +- A Flatpak counts as registered without a check, as it cannot query the host's default handler. It works only once the Flathub release pins a commit with the desktop file's MimeType line and its wrapper passes `"$@"` to the app. +- On macOS a second copy of the app does not forward a link to the running one; macOS delivers the link to the running bundle itself. +- With two installations (a deb and an AppImage, or two installed copies of the Windows app), the scheme stays with whichever registered first while it is installed; the other gets `app=desktop`. On Windows, when several apps registered through `Capabilities` handle a scheme and none was picked, Windows chooses or asks; the app cannot set `UserChoice` itself. +- The link arrives as a command-line argument, which other local users can read through `/proc` on Linux. This risk is accepted. + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt` -- registration and its result for the badge page +- `common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt` -- forwarding to a running instance + +### Impact + +A stale registration or an unregistered Flatpak sends the user back to a link that opens nothing; the badge page's *Show code* and the Redeem code screen remain. + +### Recommendation + +1. Remove the registration in an uninstaller where one exists (MSI), and on AppImage when the entry's file is gone. +2. Removing the command-line exposure needs the page to encrypt the code to a key the app passes in the URL fragment. + +--- + +## GAP-09: Desktop: Emoji Picker Without Skin Tones and Localized Search + +**Severity:** Low +**Category:** Feature / Platform +**Platform:** Desktop only + +### Description + +The desktop compose emoji picker lists emoji without skin tone variants, and its search matches English Unicode emoji names only. + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt` +- `scripts/desktop/generate-emoji-data.py` (`../../scripts/desktop/` relative to `apps/multiplatform/`) -- drops skin tone variants + +### Impact + +Desktop users cannot pick a skin tone in the picker, and search in other languages finds nothing. + +### Recommendation + +1. Keep the skin tone variants in the generated data, grouped by base emoji, and add a skin tone choice to the picker. +2. Add localized names (CLDR annotations) to the generated data for search. diff --git a/apps/multiplatform/product/views/call.md b/apps/multiplatform/product/views/call.md index 51d323874c..1d4f3b881b 100644 --- a/apps/multiplatform/product/views/call.md +++ b/apps/multiplatform/product/views/call.md @@ -71,8 +71,8 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call | 1. Capabilities | `WCallResponse.Capabilities` | Local capabilities reported; `apiSendCallInvitation()` called | | 2. Offer | `WCallResponse.Offer` | SDP offer + ICE candidates sent via `apiSendCallOffer()` | | 3. Answer | `WCallResponse.Answer` | SDP answer + ICE candidates sent via `apiSendCallAnswer()` | -| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates exchanged via `apiSendCallExtraInfo()` | -| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success | +| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates, and ICE restart offer/answer in `session`, exchanged via `apiSendCallExtraInfo()` | +| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success, `CallState.Reconnecting` when a connected call loses connection | | 6. Connected | `WCallResponse.Connected` | Connection info (relay/direct) stored in `call.connectionInfo` | | 7. PeerMedia | `WCallResponse.PeerMedia` | Remote party media source changes (mic, camera, screen) | | 8. Media control | `WCallCommand.Media` | Toggle local media sources (mic, camera, screen audio/video) | @@ -90,14 +90,15 @@ Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call | `OfferReceived` | Callee received SDP offer | | `AnswerReceived` | Caller received SDP answer | | `Negotiated` | ICE negotiation complete | -| `Connected` | WebRTC media flowing; `connectedAt` timestamp set | +| `Connected` | WebRTC media flowing; `connectedAt` timestamp set on the first connection | +| `Reconnecting` | Connection lost; "reconnecting…" shown while ICE restarts, call ends after 30 seconds without connection | | `Ended` | Call terminated | ### Call Sounds | Sound | Trigger | |---|---| -| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent | +| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent, and while reconnecting | | In-call sound | `CallSoundsPlayer.startInCallSound()` when delivery receipt received | | Ringtone | `SoundPlayer.start()` for incoming calls | | End vibration | `CallSoundsPlayer.vibrate()` on call end (if was connected) | diff --git a/apps/multiplatform/product/views/chat.md b/apps/multiplatform/product/views/chat.md index 7862574f04..c920e40146 100644 --- a/apps/multiplatform/product/views/chat.md +++ b/apps/multiplatform/product/views/chat.md @@ -95,6 +95,7 @@ Bottom input area for composing messages: |---|---| | Text field | `PlatformTextField` with markdown support, `@mention` autocomplete, file paste support | | Attachment button | Opens `ModalBottomSheetLayout` with options: camera, gallery (image/video), file | +| Emoji button | Desktop only (Android keyboards have their own emoji panel). Opens a popup with search and categories; a picked emoji is inserted at the cursor and the picker stays open. With text selected the emoji goes after it, as the text field drops its selection when the picker takes focus. Picked emoji are not remembered, for privacy. Esc, a click outside or the button closes it | | Send button | Sends message; changes to checkmark for reports; animated size/alpha | | Voice record button | Shown when text is empty and voice allowed; hold to record, release to preview | | Live message button | Start/update live typing message (if `liveMessageAlertShown`) | @@ -132,4 +133,5 @@ When `timedMessageAllowed` is true, compose bar includes a timer icon for settin | `ChatView.kt` | `views/chat/ChatView.kt` | | `ComposeView.kt` | `views/chat/ComposeView.kt` | | `SendMsgView.kt` | `views/chat/SendMsgView.kt` | +| `ComposeEmojiPicker.kt` | `views/chat/ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) | | Chat item views | `views/chat/item/*.kt` | diff --git a/apps/multiplatform/product/views/settings.md b/apps/multiplatform/product/views/settings.md index e668bf2d04..563fa09d5b 100644 --- a/apps/multiplatform/product/views/settings.md +++ b/apps/multiplatform/product/views/settings.md @@ -139,6 +139,7 @@ Platform-specific section (expect/actual composable): |---|---| | App updates (Desktop) | App update checker and installer | | Developer tools | Toggle developer mode | +| Storage (developer options) | Opens `StorageView`: size of each top-level entry in the app's data, config and temp folders | | Chat console | Opens `ChatConsoleView` terminal | | Terminal always visible (Desktop) | Keep terminal window open | | Install terminal app | Link to CLI app on GitHub | diff --git a/apps/multiplatform/product/views/user-profiles.md b/apps/multiplatform/product/views/user-profiles.md index dfc37a5e8d..38a0e13874 100644 --- a/apps/multiplatform/product/views/user-profiles.md +++ b/apps/multiplatform/product/views/user-profiles.md @@ -28,8 +28,8 @@ Overlay panel triggered from `ChatListView` toolbar: | Device picker row | `DevicePickerRow` showing local device and connected remote hosts (Desktop only); pill-shaped buttons with connect/disconnect actions | | Active user profile | `ProfilePreview` of current user (Desktop: single row; Android: full user list) | | User list | `UserPickerUsersSection` with all visible non-hidden profiles; tap to switch, long-press disabled | -| SimpleX address | Row to open `UserAddressView` (create or view address) | -| Chat preferences | Row to open `PreferencesView` | +| SimpleX address | Row to open `UserAddressView` (create or view address); hidden on Desktop when there is no active profile and no mobile is in use; a screen shown with no active profile closes itself (if another screen covers it, when the user goes back to it) | +| Chat preferences | Row to open `PreferencesView`; hidden on Desktop when there is no active profile and no mobile is in use; a screen shown with no active profile closes itself (if another screen covers it, when the user goes back to it) | | Chat profiles | Row to open `UserProfilesView` (or `CreateProfile` when no users exist on Desktop) | | Use from desktop/mobile | Android: "Use from desktop" (`ConnectDesktopView`); Desktop: "Link a mobile" / "Linked mobiles" (`ConnectMobileView`) | | Settings | Row to open `SettingsView` with `ColorModeSwitcher` trailing | diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md index d6a7ec20af..59f3fbe78a 100644 --- a/apps/multiplatform/spec/README.md +++ b/apps/multiplatform/spec/README.md @@ -107,7 +107,7 @@ Common Module (commonMain) | Concepts | [product/concepts.md](../product/concepts.md) | 30 product concepts (PC1-PC30) mapped to docs + source | | Glossary | [product/glossary.md](../product/glossary.md) | Domain term definitions (9 sections) | | Rules | [product/rules.md](../product/rules.md) | 18 business rules in 6 categories | -| Gaps | [product/gaps.md](../product/gaps.md) | 7 known gaps with recommendations | +| Gaps | [product/gaps.md](../product/gaps.md) | 8 known gaps with recommendations | | Flows | [product/flows/](../product/flows/) | onboarding, messaging, connection, calling, file-transfer, group-lifecycle | | Views | [product/views/](../product/views/) | chat-list, chat, settings, onboarding, call, new-chat, contact-info, group-info, user-profiles | @@ -119,7 +119,7 @@ Common Module (commonMain) |---|---|---|---| | Android Application | [`SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L41) | `class SimplexApp` | 41 | | Android Activity | [`MainActivity.kt`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L27) | `class MainActivity` | 27 | -| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) | `fun main()` | 21 | +| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) | `fun main(args: Array)` | 26 | | Desktop App Window | [`DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) | `fun showApp()` | 33 | | Desktop Init | [`AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) | `fun initApp()` | 21 | | Common App Screen | [`App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) | `fun AppScreen()` | 47 | @@ -128,10 +128,10 @@ Common Module (commonMain) | Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) | `object ChatModel` | 86 | | App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L102) | `class AppPreferences` | 102 | | Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) | `interface PlatformInterface` | 15 | -| Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L19) | `abstract class NtfManager` | 19 | +| Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | `abstract class NtfManager` | 20 | | Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 | -| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L33) | `fun initHaskell(packageName: String)` | 33 | -| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L41) | `fun runMigrations()` | 41 | +| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L36) | `fun initHaskell(packageName: String)` | 36 | +| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L48) | `fun runMigrations()` | 48 | | Android Service | [`SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt#L41) | `class SimplexService` | 41 | | Gradle Root | [`settings.gradle.kts`](../settings.gradle.kts#L22) | `include(":android", ":desktop", ":common")` | 22 | | Common Build | [`build.gradle.kts`](../common/build.gradle.kts#L14) | `kotlin { androidTarget(); jvm("desktop") }` | 14 | diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md index 4114e9de4f..ec7cedb715 100644 --- a/apps/multiplatform/spec/api.md +++ b/apps/multiplatform/spec/api.md @@ -225,7 +225,7 @@ All functions below are `suspend fun` members of `ChatController` ([SimpleXAPI.k | `apiRejectCall` | `rh: Long?, contact: Contact` | Reject an incoming call | [L1854](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | | `apiSendCallOffer` | `rh, contact, rtcSession, rtcIceCandidates, media, capabilities` | Send a WebRTC call offer | [L1859](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | | `apiSendCallAnswer` | `rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String` | Send a WebRTC call answer | [L1866](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | -| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcIceCandidates: String` | Send additional ICE candidates during a call | [L1872](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | +| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcSession: String?, rtcIceCandidates: String` | Send additional ICE candidates or ICE restart offer/answer during a call | [L2156](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2156) | | `apiEndCall` | `rh: Long?, contact: Contact` | End an active call | [L1878](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | | `apiCallStatus` | `rh: Long?, contact: Contact, status: WebRTCCallStatus` | Report call status updates to the core | [L1883](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md index 9911a2670f..cdc43a794a 100644 --- a/apps/multiplatform/spec/architecture.md +++ b/apps/multiplatform/spec/architecture.md @@ -222,21 +222,29 @@ Lifecycle callbacks in `SimplexApp` (implements `LifecycleEventObserver`): ### Desktop -Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) +Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) ``` -main() +main(args) + +-- linkFromArgs(args) // A simplexchat: or simplex: link passed by the OS + +-- acquireSingleInstance(link) // Or forward the link to the running instance and exit + +-- appOpenUrl = link // Stored before the watcher can forward a newer link + +-- startShowFileWatcher() // Only while holding the single-instance lock +-- initHaskell() // Load native lib from resources dir, call initHS() | +-- System.load(libapp-lib.so/dll/dylib) | +-- initHS() +-- runMigrations() +-- setupUpdateChecker() +-- initApp() // Set ntfManager, applyAppLocale, initChatControllerOnStart + +-- registerLinkSchemes() // Background thread, per-OS registration and detection + +-- installOpenUriHandler() // macOS only: links arrive as Apple Events +-- showApp() // Compose window with AppScreen() ``` [`showApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) creates a Compose `Window` with error recovery -- if a crash occurs, it closes the offending modal/view and re-opens the window. +[`showWindow()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L258) un-minimizes and raises the window when a second launch or a link reaches the running app. On Linux it also sends `_NET_ACTIVE_WINDOW` through [`requestX11Activation()`](../common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt#L17), because wlroots compositors such as Sway ignore the raise that AWT's `toFront()` sends under XWayland. On Windows the second process, which the browser left holding the foreground right, calls `AllowSetForegroundWindow` before it signals so that the running instance may raise its window. During a crash restart `showWindow()` skips the disposed window, so it does not come back; the new window opens visible. + [`initApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) sets the `ntfManager` implementation (desktop notifications via `NtfManager` in `common/model/`) and calls `initChatControllerOnStart()`. --- @@ -341,8 +349,8 @@ Examples from platform files: [`PlatformInterface`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) is an interface with default no-op implementations. It is assigned at runtime by each platform entry point: -- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L187) (line 187) -- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L50) (line 50) +- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L193) +- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L59) The global variable is declared at [`Platform.kt line 50`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L50): ```kotlin @@ -417,6 +425,8 @@ var platform: PlatformInterface = object : PlatformInterface {} | SimplexService.kt | [`android/src/main/java/chat/simplex/app/SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt) | Android foreground service | | Main.kt | [`desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt) | Desktop `main()` | | DesktopApp.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt) | `showApp()`, `SimplexWindowState` | +| SingleInstance.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt) | `acquireSingleInstance()`, `startShowFileWatcher()`, signal file hand-over | +| WindowActivation.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt) | `requestX11Activation()`, `sendActivation()`, `activationEvent()` | ### Theme diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index e34da9613d..5a63ad92bc 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -279,6 +279,9 @@ fun UserPicker( - **Android:** Renders as a slide-up overlay panel on the chat list, triggered by tapping the user profile button in the toolbar. - **Desktop:** Rendered inline in the left column of `DesktopScreen`, always accessible. - Closes automatically when any `ModalManager.start` modal opens. +- On Desktop, when there is no active user and no mobile is in use (`desktopNoUserNoRemote`), the SimpleX address and Chat preferences rows and the divider below them are hidden. +- The SimpleX address row opens `UserAddressView` in `ModalManager.start`. With no active user (`currentUser == null`) the modal draws nothing and closes itself when it is the top modal (`ModalManager.start.isLastModal`); if another screen covers it, it closes when the user goes back to it. +- The Chat preferences row closes its modal the same way when there is no active user. ### Content diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index adf8ad0224..c7d6f0674f 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -313,9 +313,10 @@ Key sections: group profile, group link, member list with roles, group preferenc | `EmojiItemView.kt` | Large emoji-only message | | `FramedItemView.kt` | Message bubble frame (quoted item, text, media) | | `ImageFullScreenView.kt` | Fullscreen image gallery | +| `MarkdownFileView.kt` | Markdown file viewer, formatted or as text | | `IntegrityErrorItemView.kt` | Message integrity error | | `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message | -| `TextItemView.kt` | Plain text message with markdown | +| `TextItemView.kt` | Plain text message with markdown; blocks of `markdownBlocks` drawn as a column of views by `MarkdownBlocks` (`MarkdownBlocks.kt`) | ### `views/chat/group/` diff --git a/apps/multiplatform/spec/client/compose.md b/apps/multiplatform/spec/client/compose.md index 241dcf667b..e7f879f5b0 100644 --- a/apps/multiplatform/spec/client/compose.md +++ b/apps/multiplatform/spec/client/compose.md @@ -14,6 +14,7 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView 6. [Attachment Handling](#6-attachment-handling) 7. [Draft Persistence](#7-draft-persistence) 8. [Source Files](#8-source-files) +9. [Emoji Picker](#9-emoji-picker) --- @@ -41,9 +42,10 @@ ComposeView | |-- ComposeImageView [media thumbnails] | |-- ComposeVoiceView [voice recording waveform] | +-- ComposeFileView [file name display] -|-- AttachmentAndCommandsButtons +|-- ComposeActionButtons | |-- CommandsButton [bot commands "//"] -| +-- AttachmentButton [paperclip icon] +| |-- AttachmentButton [paperclip icon] +| +-- ComposeEmojiButton [desktop emoji picker popup] +-- SendMsgView |-- PlatformTextField [multiline text input] |-- DeleteTextButton [clear text, shown on long text] @@ -348,7 +350,7 @@ When `privacyLinkPreviews` is enabled and the message contains a URL: ## 7. Draft Persistence -**Location:** [`ComposeView.kt#L1230`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1230) (`KeyChangeEffect(chatModel.chatId.value)`) +**Location:** [`ComposeView.kt#L1394`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1394) (`KeyChangeEffect(chatModel.chatId.value)`) Controlled by the `privacySaveLastDraft` preference. @@ -397,3 +399,27 @@ When a draft exists for a chat, `ChatPreviewView` shows a pencil icon with the d | `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions | | `ComposeContextProfilePickerView.kt` | Profile picker in compose context | | `SelectableChatItemToolbars.kt` | Multi-select mode toolbar (delete, forward, moderate) | +| `ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) | Desktop emoji button and picker, emoji insertion | + +--- + + + +## 9. Emoji Picker + +**Source:** [`ComposeEmojiPicker.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt), [`ComposeEmojiPicker.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt) + +Desktop only; on Android the [`ComposeEmojiButton()`](../../common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt) actual is empty, as keyboards have their own emoji panel. + +An emoji button in [`ComposeActionButtons()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1346-L1363) toggles `emojiPickerShown`, owned by `ComposeView`. A picked emoji goes through [`onEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1290-L1292): [`insertingEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt#L10-L14) replaces the selection (clamped to the text) and moves the cursor after the emoji, then `onMessageChange` applies markdown parsing, link sanitizing and the large-emoji font. Emoji are ignored while a message is in progress. The picker stays open after picking. The text field collapses its selection to the end when the popup takes focus, so the emoji goes after selected text. + +[`emojiEnabled`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1282-L1288) is false when sending is not allowed or a voice message is previewed; the picker closes then. When the picker closes, the text field gets focus through `focusRequester`. + +| Function | Behavior | +|---|---| +| [`ComposeEmojiButton()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L89-L115) | Button 4 dp after the attachment button (`EMOJI_BUTTON_START_PADDING`, against the row's -8 dp overlap). Focusable `Popup` above the button; closes on Esc (on key release, so the window does not also close its top modal), outside click or the button; its height is capped to the space above the button | +| [`EmojiPicker()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L127-L178) | Search field, category tabs, `LazyVerticalGrid` with one section per `EmojiCategory`; tabs and cells are not focusable, so typing after a click goes to search. Picked emoji are not stored, so nothing about what the user typed is kept in preferences | + +Data: `desktopMain/resources/emoji/emoji.txt` is generated by `scripts/desktop/generate-emoji-data.py` from Unicode `emoji-test.txt` 15.0 (the version covered by the bundled NotoColorEmoji): fully-qualified emoji without skin tone variants, `@` lines followed by `\t` lines. [`parseEmojiData()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L59-L76) maps groups to `EmojiCategory` and fails on an unknown group; [`emojiSections`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L78-L82) loads it lazily. [`searchEmoji()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L84-L87) matches the English name case-insensitively. + +[GAP] No skin tone selection, and search uses English emoji names only. diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index 67c3b00d88..4900cfaba7 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -107,6 +107,14 @@ When onboarding is complete: 1. Shows "advertise lock" alert if conditions met (not shown before, LA not enabled, >3 chats, no active call). 2. Routes to `AndroidScreen` or `DesktopScreen` based on platform. +### URL Deep Link + +On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/MainActivity.kt#L144) puts the URI of a VIEW intent into `chatModel.appOpenUrl`. Once onboarding is complete, a `LaunchedEffect` in `MainScreen` ([`App.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L241)) hands it to [`connectIfOpenedViaUri()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L775), which re-queues it while no profile is active. + +On desktop the same state is set from three places, for `simplexchat:` app links and `simplex:` connection links alike. [`isAcceptedLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L12) accepts either scheme within 8192 bytes in UTF-8, which leaves room for a one-time link with its post-quantum key (about 2 KB) and for a second such key. A link that starts the app arrives as the only argument of [`main()`](../../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) on Windows and Linux ([`linkFromArgs()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L15)). A link for an app that is already running starts a second process, which writes it into the single-instance signal file ([`signalRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L138)); the running instance renames the file before reading and deleting it ([`takeSignal()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L159)), retrying once if another program holds the file open, handles a signal already present when its watcher ([`watchShowSignals()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L247)) starts unless it predates the lock attempt by more than 2 s ([`deleteStaleSignalFiles()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L116)), and passes the link to [`openDesktopLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L19), which queues it for the active host and shows the window. If the running instance does not take the signal within 1 s, the second process asks whether to start anyway ([`startDespiteRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L93)); on *Yes* it withdraws the signal first and exits instead if the running instance has recovered and taken it, so the link opens once. On macOS the link is an Apple Event handled by [`installOpenUriHandler()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L25). From there `connectIfOpenedViaUri()` handles the link as on Android: a connection link goes to `planAndConnect()`, which asks before connecting. Both schemes are declared by the macOS Info.plist, the deb, AppImage and Flatpak desktop entries, and at runtime by [`registerLinkSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L67) (HKCU on Windows, a hidden desktop entry for an AppImage). Only `simplexchat:` is checked: [`appLinkSchemeRegistered()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L65) decides the badge page's ending through [`badgePageUrl()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt#L53): `app=true` when a link is expected to reach this installation, otherwise `app=desktop` with the Redeem code screen opened beside the browser. [`desktopInstallation()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L74) tells the installation kind apart. A packaged macOS app and a Flatpak count as registered without a check, since a Flatpak cannot query the host's default handler ([GAP](../../product/gaps.md#gap-08-desktop-app-link-registration)); Windows, where each scheme key is paired with a `SimpleX.` ProgId named in `Capabilities` and listed in `RegisteredApplications`, so the app appears in Windows default-app settings for each scheme that no other program holds, when the shell (`AssocQueryStringW`) reports this exe as the program that opens `simplexchat:` links, which accounts for any app picked in settings ([`registerWindowsSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L168)); an AppImage ([`registerAppImageScheme()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L299)) or deb when its own desktop entry is the default `simplexchat:` handler. Registration takes a scheme only when no handler is set or the current one is no longer installed: another installed program, such as a deb beside an AppImage or on Windows a per-user or machine-wide handler, keeps it, while this app still repairs its own ProgId unless another installed copy holds it, and the page then ends with `app=desktop`. An AppImage is not registered ([`onlyOwnerCanReplace()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L284)) when its file or any directory above it belongs to a user other than this user or root, or has permission bits that let others write it (POSIX ACLs are not inspected), or when its path contains a line break or `%`. An unpackaged run registers nothing. + +App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectIfOpenedViaUri()` branches on that scheme before the connection dispatch, so an app link never reaches `planAndConnect()`. [`isAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L797) tests the raw prefix, since text that does not parse as a URI is still an app link, and [`openAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L804) dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L282), which shows `BadgesRedeemLinkView` as a `ModalManager.start` modal with `ModalViewId.BADGE_LINK`. Once the code parses, every modal is closed first, so the screen opens over the chat list, as iOS dismisses every sheet before a link; a code that does not parse gets an alert and closes nothing. The screen names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the screen composes), which core would refuse, the screen does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or back closes the screen without cancelling anything; if the redemption then succeeds with the screen closed or covered, an alert says the badge was added and names the profile, unless the profile is hidden and no longer active, when nothing is shown ([`showBadgeAddedAlert()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L97), which the store lane shares). Its step is held by the modal rather than remembered by the composition, so rotation and being covered do not reset it. While the screen shows a redemption in flight ([`isBadgeLinkIssuing()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L279)), a further app link is ignored. Any other path is a link type from a later version, and gets an alert asking to check for app updates; text that does not parse is dropped. An app link can carry a secret, and nothing on this path logs the URI: `processIntent()` logs nothing, and `connectIfOpenedViaUri()` logs a fixed string. + ### Overlay Layers (bottom of MainScreen) | Layer | Condition | Content | @@ -115,8 +123,8 @@ When onboarding is complete: | `SwitchingUsersView` | User switch in progress | Loading overlay | | Auth gate | `userAuthorized != true` | `AuthView` or `SplashView` + passcode | | Active call | `showCallView == true` | `ActiveCallView` (desktop) or call activity (Android) | -| One-time passcode | Always | `ModalManager.fullscreen.showOneTimePasscodeInView` | -| Privacy alerts | Always | `AlertManager.privacySensitive` | +| One-time passcode | `userAuthorized == true` | `ModalManager.fullscreen.showOneTimePasscodeInView` | +| Privacy alerts | `userAuthorized == true` | `AlertManager.privacySensitive` | | Incoming call | `activeCallInvitation != null` | `IncomingCallAlertView` | | Shared alerts | Always | `AlertManager.shared` | @@ -178,7 +186,7 @@ fun DesktopScreen(userPickerState: MutableStateFlow) |---|---|---| | **Left** | `DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier` (fixed) | `StartPartOfScreen` (ChatListView) + `UserPicker` overlay | | **Left overlay** | Same as left column | `ModalManager.start` modals + `SwitchingUsersView` | -| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView or "no selected chat" placeholder, or `ModalManager.center`) | +| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView with `ModalManager.center` over it; without a chat: `ModalManager.center` or "no selected chat" placeholder) | | **Right** | `max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier` (flexible, 0 when empty) | `ModalManager.end` (ChatInfoView, GroupChatInfoView, ChatItemInfoView, etc.) | ### Column Separators @@ -196,7 +204,7 @@ When the UserPicker is visible or a start modal is open (but no center modal), a - When `chatId` is null and no center modals: shows "No selected chat" placeholder. - When `chatId` is null and center modals open: shows `ModalManager.center`. -- When `chatId` is set: shows `ChatView`. +- When `chatId` is set: shows `ChatView`, and `ModalManager.center` over it. - Automatically closes center modals when a chat is selected. ### StartPartOfScreen @@ -223,7 +231,7 @@ class ModalManager(private val placement: ModalPlacement?) | Zone | Android Behavior | Desktop Behavior | |---|---|---| | `start` | Shared (same as all others) | Left column overlay, slides from start | -| `center` | Shared | Center column overlay, replaces ChatView | +| `center` | Shared | Center column overlay, drawn over ChatView | | `end` | Shared | Right column, slides from end | | `fullscreen` | Shared | Fullscreen overlay | @@ -256,6 +264,7 @@ Each `ModalManager` maintains a stack of `ModalViewHolder` objects with: | `closeModals` | Pop all modals from the stack | | `closeModalsExceptFirst` | Pop all but the bottom modal | | `hasModalsOpen()` | Check if any modals are on the stack | +| `isLastModal(data)` | Check if the modal with this `ModalData` is the top modal not being removed | | `showInView` | Render the current modal stack into the composable tree | ### Usage Pattern @@ -294,7 +303,7 @@ object AppLock { ### Authentication Flow 1. **MainScreen** checks `unauthorized` (derived: `userAuthorized.value != true`) at line ~135. -2. If unauthorized and not in an active call: +2. If unauthorized, including during an active call: - Launches `AppLock.runAuthenticate()` which triggers platform-specific biometric/passcode prompt. - On Android with system auth finishing during activity destruction, authentication is skipped. 3. If `performLA` preference is set and `laFailed` is true: shows `AuthView` with "Unlock" button. @@ -302,7 +311,7 @@ object AppLock { ### Lock Delay -The `laLockDelay` preference controls how long after backgrounding the app requires re-authentication. When `laLockDelay == 0`, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth. A call counts as activity: `recheckAuthState()` is a no-op while `showCallView` is true, and `CallManager.endCall()` resets `enteredBackground`, so the delay is counted from the end of the call. +The `laLockDelay` preference controls how long after backgrounding the app requires re-authentication. When `laLockDelay == 0`, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth. A call counts as activity: `recheckAuthState()` is a no-op while `showCallView` is true, and `CallManager.endCall()` resets `enteredBackground`, so the delay is counted from the end of the call. When a call is accepted from a notification or from the Android lock screen, `recheckAuthState()` is called before `acceptIncomingCall()`, so an expired delay still locks the app. ### Lock Modes @@ -376,3 +385,5 @@ The stage value is stored in `appPrefs.onboardingStage` and persisted across app | `views/SplashView.kt` | Splash / loading screen | | `views/call/CallView.kt` | In-call fullscreen view (ActiveCallView) | | `views/localauth/PasswordEntry.kt` | Column divider utility (contains VerticalDivider) | +| `AppLinks.kt` (desktopMain) | Desktop app links from launch arguments and macOS Apple Events | +| `platform/AppLinkScheme.desktop.kt` (desktopMain) | Runtime `simplexchat:` registration and its result for the badge page | diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index ec302e30b5..6ae1e8d9f6 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -111,6 +111,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features | | `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition — send path for all messages | | `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle | +| `views/chat/ComposeEmojiPicker.kt` | PC4 | Low | Emoji button expect declaration, emoji insertion | | `views/chat/ComposeVoiceView.kt` | PC9 | Medium | Voice message recording UI | | `views/chat/ComposeFileView.kt` | PC10 | Low | File attachment preview in compose area | | `views/chat/ComposeImageView.kt` | PC10 | Low | Image attachment preview in compose area | @@ -220,6 +221,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `views/usersettings/SetDeliveryReceiptsView.kt` | PC2 | Low | Delivery receipts toggle | | `views/usersettings/RTCServers.kt` | PC17, PC25 | Medium | WebRTC ICE server configuration | | `views/usersettings/DeveloperView.kt` | — | Low | Developer/debug settings | +| `views/usersettings/StorageView.kt` | — | Low | Storage breakdown (developer options) | | `views/usersettings/HelpView.kt` | — | Low | Help and support links | | `views/usersettings/MarkdownHelpView.kt` | PC4 | Low | Markdown formatting guide | | `views/usersettings/VersionInfoView.kt` | — | Low | Version display | @@ -364,6 +366,7 @@ Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/` | `views/call/CallAudioDeviceManager.kt` | PC17 | Medium | Android audio routing (speaker, earpiece, bluetooth) | | `views/chat/ComposeView.android.kt` | PC4, PC10 | Low | Android compose view extensions | | `views/chat/SendMsgView.android.kt` | PC4 | Low | Android send button extensions | +| `views/chat/ComposeEmojiPicker.android.kt` | PC4 | Low | Empty emoji button actual (no picker on Android) | | `views/chat/item/ChatItemView.android.kt` | PC2, PC3 | Low | Android chat item extensions | | `views/chat/item/CIImageView.android.kt` | PC10 | Low | Android image rendering extensions | | `views/chat/item/CIVideoView.android.kt` | PC10 | Low | Android video rendering extensions | @@ -415,6 +418,10 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` |-------------|--------------------------|------------|-------| | `DesktopApp.kt` | PC1, PC2, PC3 | High | Desktop Compose window — window lifecycle, crash recovery | | `StoreWindowState.kt` | — | Low | Window position/size persistence | +| `AppLinks.kt` | — | Medium | `simplexchat:` and `simplex:` links from launch arguments and macOS Apple Events | +| `SingleInstance.kt` | — | Medium | Single-instance lock; forwards a second launch and its link to the running app | +| `WindowActivation.kt` | — | Low | `_NET_ACTIVE_WINDOW` request that raises the window on X11 and XWayland | +| `platform/AppLinkScheme.desktop.kt` | — | Medium | Runtime `simplexchat:` and `simplex:` scheme registration (Windows HKCU, AppImage desktop entry) | | `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display | | `platform/AppCommon.desktop.kt` | PC1 through PC31 | Medium | Desktop app initialization actual declarations | | `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) | @@ -438,6 +445,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` | `views/call/CallView.desktop.kt` | PC17 | Medium | Desktop WebView-based WebRTC call | | `views/chat/ComposeView.desktop.kt` | PC4, PC10 | Low | Desktop compose view (drag-and-drop, paste) | | `views/chat/SendMsgView.desktop.kt` | PC4 | Low | Desktop send shortcut (Enter key handling) | +| `views/chat/ComposeEmojiPicker.desktop.kt` | PC4 | Low | Desktop emoji picker popup (data from `resources/emoji/emoji.txt`, search) | | `views/chat/item/ChatItemView.desktop.kt` | PC2, PC3 | Low | Desktop chat item extensions | | `views/chat/item/CIImageView.desktop.kt` | PC10 | Low | Desktop image rendering | | `views/chat/item/CIVideoView.desktop.kt` | PC10 | Low | Desktop video rendering | diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md index 0f68c5a1fd..a46de4f45c 100644 --- a/apps/multiplatform/spec/services/calls.md +++ b/apps/multiplatform/spec/services/calls.md @@ -43,6 +43,7 @@ enum class CallState { AnswerReceived, // SDP answer received from peer Negotiated, // ICE negotiation in progress Connected, // Media flowing + Reconnecting, // Connection lost, waiting for ICE to recover Ended; // Call terminated } ``` @@ -53,24 +54,26 @@ enum class CallState { State transitions are driven by `WCallResponse` messages from the WebRTC layer. Each transition typically triggers a corresponding API command (e.g., `apiSendCallInvitation`, `apiSendCallOffer`). +**Reconnection**: `Connected` -> `Reconnecting` -> `Connected` or `Ended`. When a connected call loses its WebRTC connection, `call.js` reports `"reconnecting"` connection state instead of ending the call. The app sets `CallState.Reconnecting` and plays the connecting sound; this state is not sent to the core with `apiCallStatus`, so the call item duration is not affected, and `connectedAt` is kept. The party that sent the initial offer (callee) restarts ICE every 10 seconds until reconnected: the restart offer and the answer are sent as `WCallResponse.Ice` / `WCallCommand.Ice` with `session`, via `apiSendCallExtraInfo` (`x.call.extra` with `rtcSession`). If the connection is not restored in 30 seconds, `call.js` reports `"disconnected"` and ends the call. + --- ## 3. Android Implementation -### 3.1 CallActivity.kt (464 lines) +### 3.1 CallActivity.kt (468 lines) [`CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) A dedicated `ComponentActivity` that hosts the call UI. Key responsibilities: -- **Intent handling** ([line 64](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L64)): On `AcceptCallAction` intent, looks up the matching `RcvCallInvitation` and calls `callManager.acceptIncomingCall()`. -- **Lock screen support** ([line 160](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L160)): `unlockForIncomingCall()` uses `setShowWhenLocked(true)` / `setTurnScreenOn(true)` on API 27+, falls back to window flags on older versions. `lockAfterIncomingCall()` reverses these settings. -- **Picture-in-Picture** ([line 99](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L99)): `setPipParams()` configures PiP aspect ratio and source rect hint. On Android 12+ (`Build.VERSION_CODES.S`), auto-enter PiP is enabled for video calls. `onPictureInPictureModeChanged` toggles `activeCallViewIsCollapsed` and sends a `WCallCommand.Layout` command. -- **Permission checks** ([line 122](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L122)): Checks `RECORD_AUDIO` and conditionally `CAMERA` permissions. -- **Service binding** ([line 181](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L181)): Binds to `CallService` as a workaround for Android 12 background activity launch restrictions. -- **CallActivityView composable** ([line 208](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L208)): Renders `ActiveCallView()` when permissions are granted and a call is active. Shows `CallPermissionsView` when permissions are needed. Shows `IncomingCallLockScreenAlert` for incoming calls on the lock screen. +- **Intent handling** ([line 65](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L65)): On `AcceptCallAction` intent, looks up the matching `RcvCallInvitation` and calls `callManager.acceptIncomingCall()`. +- **Lock screen support** ([line 161](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L161)): `unlockForIncomingCall()` uses `setShowWhenLocked(true)` / `setTurnScreenOn(true)` on API 27+, falls back to window flags on older versions. `lockAfterIncomingCall()` reverses these settings. +- **Picture-in-Picture** ([line 100](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L100)): `setPipParams()` configures PiP aspect ratio and source rect hint. On Android 12+ (`Build.VERSION_CODES.S`), auto-enter PiP is enabled for video calls. `onPictureInPictureModeChanged` toggles `activeCallViewIsCollapsed` and sends a `WCallCommand.Layout` command. +- **Permission checks** ([line 123](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L123)): Checks `RECORD_AUDIO` and conditionally `CAMERA` permissions. +- **Service binding** ([line 182](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L182)): Binds to `CallService` as a workaround for Android 12 background activity launch restrictions. +- **CallActivityView composable** ([line 209](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L209)): Renders `ActiveCallView()` when permissions are granted and a call is active. Shows `CallPermissionsView` when permissions are needed. Shows `IncomingCallLockScreenAlert` for incoming calls on the lock screen. ### 3.2 CallService.kt (207 lines) @@ -140,7 +143,7 @@ Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/com | `apiRejectCall` | [L1854](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | Reject an incoming call | | `apiSendCallOffer` | [L1859](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | Send SDP offer with ICE candidates and capabilities | | `apiSendCallAnswer` | [L1866](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | Send SDP answer with ICE candidates | -| `apiSendCallExtraInfo` | [L1872](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | Send additional ICE candidates discovered after initial exchange | +| `apiSendCallExtraInfo` | [L2156](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2156) | Send additional ICE candidates discovered after initial exchange, or ICE restart offer/answer in `rtcSession` | | `apiEndCall` | [L1878](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | Terminate a call | | `apiCallStatus` | [L1883](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | Report WebRTC connection status to the backend | @@ -169,7 +172,7 @@ An in-app notification banner shown when a call invitation arrives while the app | `CallView.kt` | [`common/src/commonMain/.../views/call/CallView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt) | 28 | `expect fun ActiveCallView()`, delivery receipt waiting | | `CallView.android.kt` | [`common/src/androidMain/.../views/call/CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) | 891 | Android WebView WebRTC, overlay, permissions | | `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 308 | Desktop browser WebRTC via NanoWSD | -| `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 464 | Android call Activity, PiP, lock screen | +| `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 472 | Android call Activity, PiP, lock screen | | `CallService.kt` | [`android/src/main/java/.../CallService.kt`](../../android/src/main/java/chat/simplex/app/CallService.kt) | 207 | Android foreground service for calls | | `CallManager.kt` | [`common/src/commonMain/.../views/call/CallManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt) | 119 | Call lifecycle management | | `WebRTC.kt` | [`common/src/commonMain/.../views/call/WebRTC.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt) | -- | `CallState` enum, `WCallCommand`, `WCallResponse` types | diff --git a/apps/multiplatform/spec/services/notifications.md b/apps/multiplatform/spec/services/notifications.md index a14c4d7d24..b9391dda11 100644 --- a/apps/multiplatform/spec/services/notifications.md +++ b/apps/multiplatform/spec/services/notifications.md @@ -35,16 +35,16 @@ The architecture uses an abstract `NtfManager` in common code with platform-spec [`NtfManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) (139 lines, commonMain) -The global `ntfManager` instance is declared at [line 17](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L17) and initialized by each platform at startup. +The global `ntfManager` instance is declared at [line 18](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L18) and initialized by each platform at startup. ### Concrete methods | Method | Line | Description | |---|---|---| -| `notifyContactConnected` | [L20](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | Displays "contact connected" notification for a `Contact` | -| `notifyContactRequestReceived` | [L27](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L27) | Shows contact request notification with an "Accept" action button | -| `notifyMessageReceived` | [L38](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L38) | Conditionally shows message notification based on `ntfsEnabled`, `showNotification`, and whether user is viewing that chat | -| `acceptContactRequestAction` | [L51](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L51) | Accepts a contact request from a notification action | +| `notifyContactConnected` | [L21](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L21) | Displays "contact connected" notification for a `Contact` | +| `notifyContactRequestReceived` | [L28](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L28) | Shows contact request notification with an "Accept" action button | +| `notifyMessageReceived` | [L39](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L39) | Conditionally shows message notification based on `ntfsEnabled`, `showNotification`, and whether user is viewing that chat | +| `acceptContactRequestAction` | [L52](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L52) | Accepts a contact request from a notification action | | `openChatAction` | [L59](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L59) | Opens a specific chat from a notification tap, switching user if needed | | `showChatsAction` | [L74](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L74) | Opens the chat list, switching user if needed | | `acceptCallAction` | [L88](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L88) | Accepts a call invitation from a notification action | diff --git a/apps/simplex-badge-service/README.md b/apps/simplex-badge-service/README.md index 83b7ca4c40..bb1cec2230 100644 --- a/apps/simplex-badge-service/README.md +++ b/apps/simplex-badge-service/README.md @@ -51,7 +51,7 @@ simplex-badge-service --help `simplex-directory-service --run-cli`). This mode is the chat/RPC side and the `//` commands below: it starts no web listener and no poller, and serves no group commands, whatever `--service-config` says. It still updates a code's group message when the code is redeemed or - revoked. + revoked. `[apple]`, `[google]` and `[dev] accept_unverified_store_receipts` apply to it. - `--no-address`: skip address creation on start-up (for operators who provision the address themselves). The service cannot sign credentials without an issuer key and refuses to start without one: @@ -84,7 +84,8 @@ Other options: - `--service-config INI_FILE`: path to `badge_service.ini`. Omit it to run the chat/RPC side only; the process never starts a web listener without it, and never starts one under `--run-cli`, which parses and validates the whole file (`[listener] static_dir` included) but - uses only its `[issuer]` section. An issuer key is still required either way. + uses only its `[issuer]`, `[apple]` and `[google]` sections and `[dev] accept_unverified_store_receipts`. An issuer key is + still required either way. - `--service-name NAME`: the bot's display name, without `*`s or spaces (default `SimpleX Badges`). - `--client-service`: use the client service certificate. - also accepts the standard SimpleX Chat core options — database path, SMP/XFTP servers, @@ -208,6 +209,68 @@ the exception: each answers 200, 400 or 413 with an empty body, because its prov caller and nothing it could read would change what the route does. A wrong verb on any route, those two included, answers `method_not_allowed`. +### Store purchases (App Store and Google Play) + +Each store is verified only when its section is present; without one, every receipt from that store +is answered `provider_not_configured` and the app keeps the purchase to present again. A section +missing a key, a root certificate that cannot be read or is not a root, an unreadable service +account key, or either section beside +`[dev] accept_unverified_store_receipts = on`, stops the service at startup. + +```ini +[apple] +bundle_id = chat.simplex.app +root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer + +[google] +package_name = chat.simplex.app +service_account_file = /etc/simplex-badge-service/play-service-account.json +``` + +- `[apple]` verifies the signed transaction offline, against the roots `root_certificates` lists, + comma-separated, each in DER. Today that is Apple Root CA - G3 as Apple publishes it at + . Each must be self-signed. + Verification reads no validity period, so an expired root keeps verifying. A transaction for + another bundle id, or an intermediate no listed root signed, is answered `internal` and logged as + an error rather than refused, since `bundle_id` may be wrong or a root missing; check both when those errors appear, unless the purchase was made in Xcode's + StoreKit testing, which the App Store does not sign, so no root verifies it. When Apple signs + under a new root, list it beside the old one rather than in its place: a purchase signed under the + old root still verifies, and one answered `internal` meanwhile is credited when the app presents + it again. +- `[google]` asks the Google Play Developer API about each purchase, and acknowledges it before + crediting it, as the service account whose JSON key `service_account_file` holds. The account + needs both Play Console permissions Google's + [getting started](https://developers.google.com/android-publisher/getting_started) lists for its + billing APIs: "View financial data, orders, and cancellation survey responses" to read a + purchase, and "Manage orders and subscriptions" to acknowledge it. Without the second, a Play + purchase the app has not already acknowledged itself is answered `internal` and logged as an + error naming the acknowledgement, and the app presents it again once the permission is granted, + which must be within three days, before Play refunds it. The key signs in + for an access token, so keep the file readable by this service alone. + Play purchases are verified on a lane of their own, so a slow or unreachable Play delays only + other Play purchases, not code redemptions, renewals or App Store purchases. + +Which answers are terminal for a purchase and which leave it to be presented again is listed per +store in [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md#commands). + +### Accepting store receipts unverified, for local testing + +```ini +[dev] +accept_unverified_store_receipts = on +``` + +Without a store verifier every store purchase is answered `provider_not_configured`. With this on, +the service accepts any well-formed receipt without asking or verifying anything: an Apple JWS for +the `transactionId` and `productId` its payload names, unsigned or not, and a Play product id and +token as given. It applies to both run modes, since `--run-cli` answers service requests too, and +the service logs a warning at every start while it is on. Off by default, and only `on`/`off` +parse. The service refuses to start with it on beside an `[apple]` or `[google]` section. + +With it on, anyone who can reach the service address can mint badges by sending a made-up receipt. +That is harmless only while this deployment signs with an issuer key released apps do not carry, so +they refuse what it signs — never turn it on where the key is one those apps trust. + ## Issuing codes Operators issue codes two ways: from the service's own command line in `--run-cli` mode, and from the diff --git a/apps/simplex-badge-service/badge_service.ini.example b/apps/simplex-badge-service/badge_service.ini.example index 00584fed41..26abb5aa7e 100644 --- a/apps/simplex-badge-service/badge_service.ini.example +++ b/apps/simplex-badge-service/badge_service.ini.example @@ -40,6 +40,25 @@ session_minutes = 60 waiting_seconds = 3 idle_seconds = 60 +; verifies App Store purchases offline. Omit the whole section and the service answers every App Store +; receipt provider_not_configured, which the app retries later. +; root_certificates lists, comma-separated, the roots trusted to sign receipts, each in DER. Today that +; is Apple Root CA - G3 as Apple publishes it: https://www.apple.com/certificateauthority/AppleRootCA-G3.cer +; When Apple adds a root, list it beside the old one rather than in its place. +;[apple] +;bundle_id = chat.simplex.app +;root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer + +; verifies Play purchases with the Google Play Developer API. Omit the whole section and the service answers +; every Play receipt provider_not_configured, which the app retries later. +; service_account_file is the JSON key Google issues for a service account with the Play Console +; permissions "View financial data, orders, and cancellation survey responses" and "Manage orders and +; subscriptions", to read and acknowledge purchases. Without the second, a purchase the app has not +; already acknowledged itself is answered internal until the permission is granted. Keep the file readable by this service alone. +;[google] +;package_name = chat.simplex.app +;service_account_file = /etc/simplex-badge-service/play-service-account.json + ; the key this service signs credentials with. index is the index clients verify against, so ; private_key has to be one whose public half already ships in the apps at that index: a fresh ; `simplex-chat badge keygen` pair is refused until it does. @@ -60,3 +79,9 @@ idle_seconds = 60 ;[group] ;display_name = SimpleX Badges ;description = Welcome to the badges group + +; local testing only, in both run modes: accepts any well-formed App Store or Play receipt without +; verifying it, so anyone who can reach this service can mint badges. The service refuses to start +; with this on beside an [apple] or [google] section. +[dev] +accept_unverified_store_receipts = off diff --git a/apps/simplex-badge-service/src/BadgeService/Catalog.hs b/apps/simplex-badge-service/src/BadgeService/Catalog.hs index b15185da68..85b678d8d5 100644 --- a/apps/simplex-badge-service/src/BadgeService/Catalog.hs +++ b/apps/simplex-badge-service/src/BadgeService/Catalog.hs @@ -11,6 +11,8 @@ module BadgeService.Catalog priceOffer, catalogCurrency, defaultCatalog, + StoreProduct (..), + storeProduct, ) where @@ -21,7 +23,7 @@ import Data.Word (Word32, Word64, Word8) import Simplex.Chat.Badges (BadgeType (..)) import Simplex.Chat.Badges.Service (BadgeOffer (..), BadgePrice (..)) import Simplex.Chat.Badges.Types (BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..)) -import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), PaymentProvider (..)) data OfferInvalid = OIZeroMonths | OIFreeMonthsExceedTerm | OIDiscountTooLarge | OIAmountUnsellable deriving (Eq, Show) @@ -133,3 +135,20 @@ defaultCatalog seededAt = (prices, offers) status = BISActive, createdAt = seededAt } + +data StoreProduct = StoreProduct + { badgeType :: BadgeType, + months :: Int + } + deriving (Eq, Show) + +-- | A receipt proves only the product paid for, so the badge comes from here, never from the client. +-- Mirrors the one-time SKUs of BadgeStore.swift and .kt; subscriptions wait until renewals add months. +-- Not part of the seeded catalog above, which exists to pin prices this service sets: a store sets its own. +storeProduct :: PaymentProvider -> Text -> Maybe StoreProduct +storeProduct provider productId = case (provider, productId) of + (PPApple, "BADGE_SUPPORTER_01") -> Just $ StoreProduct BTSupporter 1 + (PPApple, "BADGE_LEGEND_01") -> Just $ StoreProduct BTLegend 1 + (PPGoogle, "badge_supporter_01") -> Just $ StoreProduct BTSupporter 1 + (PPGoogle, "badge_legend_01") -> Just $ StoreProduct BTLegend 1 + _ -> Nothing diff --git a/apps/simplex-badge-service/src/BadgeService/Config.hs b/apps/simplex-badge-service/src/BadgeService/Config.hs index 9e33f4459d..72e774fdac 100644 --- a/apps/simplex-badge-service/src/BadgeService/Config.hs +++ b/apps/simplex-badge-service/src/BadgeService/Config.hs @@ -10,6 +10,8 @@ module BadgeService.Config SpeedPolicy (..), speedPolicyName, PollConfig (..), + AppleStoreConfig (..), + PlayStoreConfig (..), BadgeIssuerKey (..), GroupConfig (..), ServiceConfig (..), @@ -22,11 +24,13 @@ where import qualified Control.Exception as E import BadgeService.Log (logWarn) -import Control.Monad (mfilter) +import Control.Monad (mfilter, when) import Data.Attoparsec.Text (Parser, endOfInput, isEndOfLine, parseOnly, satisfy, skipMany, skipSpace, skipWhile) import qualified Data.ByteString.Char8 as B import Data.Ini (Ini, iniGlobals, iniParser, keys, lookupValue, sections) -import Data.Maybe (fromMaybe) +import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L +import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as TIO @@ -91,6 +95,20 @@ instance Show StripeConfig where data PollConfig = PollConfig {pWaitingSeconds :: Int, pIdleSeconds :: Int} deriving (Eq, Show) +data AppleStoreConfig = AppleStoreConfig + { aBundleId :: Text, + aRootCertificates :: NonEmpty FilePath + } + deriving (Eq, Show) + +data PlayStoreConfig = PlayStoreConfig + { gPackageName :: Text, + gServiceAccountFile :: FilePath, + gApiHost :: Text, + gTokenUrl :: Text + } + deriving (Eq, Show) + data BadgeIssuerKey = BadgeIssuerKey { keyIdx :: Int, secretKey :: BBSSecretKey @@ -112,8 +130,12 @@ data ServiceConfig = ServiceConfig btcpay :: Maybe BTCPayConfig, stripe :: Maybe StripeConfig, poll :: PollConfig, + appleStore :: Maybe AppleStoreConfig, + playStore :: Maybe PlayStoreConfig, issuer :: Maybe BadgeIssuerKey, - group :: Maybe GroupConfig + group :: Maybe GroupConfig, + -- Local testing only; anyone who can reach the service can mint badges with any well-formed receipt. + devAcceptUnverifiedStoreReceipts :: Bool } deriving (Eq, Show) @@ -126,6 +148,12 @@ defaultStripeHost = "https://api.stripe.com" defaultSessionMinutes :: Int defaultSessionMinutes = 60 +defaultPlayApiHost :: Text +defaultPlayApiHost = "https://androidpublisher.googleapis.com" + +defaultPlayTokenUrl :: Text +defaultPlayTokenUrl = "https://oauth2.googleapis.com/token" + -- | At most one day, so a card order expires long before the poller stops checking it at 72 hours. minSessionMinutes, maxSessionMinutes :: Int minSessionMinutes = 1 @@ -164,7 +192,10 @@ knownSettings = ("btcpay", ["host", "api_key", "store_id", "webhook_secret", "expiry_minutes", "speed_policy", "payment_tolerance"]), ("stripe", ["secret_key", "publishable_key", "webhook_secret", "session_minutes"]), ("poll", ["waiting_seconds", "idle_seconds"]), + ("apple", ["bundle_id", "root_certificates"]), + ("google", ["package_name", "service_account_file"]), ("group", ["display_name", "description"]), + ("dev", ["accept_unverified_store_receipts"]), ("issuer", ["index", "private_key"]) ] @@ -196,14 +227,22 @@ parseConfig ini = do grp <- groupSection pWaitingSeconds <- cadence "waiting_seconds" 3 pIdleSeconds <- cadence "idle_seconds" 60 + apple <- appleSection + google <- googleSection + devUnverifiedReceipts <- bool "dev" "accept_unverified_store_receipts" False + when (devUnverifiedReceipts && (isJust apple || isJust google)) $ + Left "[dev] accept_unverified_store_receipts must be off where [apple] or [google] verifies store receipts" pure ServiceConfig { listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor}, btcpay = btc, stripe = str, poll = PollConfig {pWaitingSeconds, pIdleSeconds}, + appleStore = apple, + playStore = google, issuer = iss, - group = grp + group = grp, + devAcceptUnverifiedStoreReceipts = devUnverifiedReceipts } where hasSection s = s `elem` sections ini @@ -295,6 +334,22 @@ parseConfig ini = do sSessionMinutes <- sessionMinutes let sHost = defaultStripeHost pure (Just StripeConfig {sSecretKey, sPublishableKey, sWebhookSecret, sSessionMinutes, sHost}) + appleSection + | not (hasSection "apple") = Right Nothing + | otherwise = do + aBundleId <- required "apple" "bundle_id" + aRootCertificates <- rootCertificates + pure (Just AppleStoreConfig {aBundleId, aRootCertificates}) + rootCertificates = do + v <- required "apple" "root_certificates" + maybe (Left "apple.root_certificates must list at least one file") Right $ + L.nonEmpty $ map T.unpack $ filter (not . T.null) $ map T.strip $ T.splitOn "," v + googleSection + | not (hasSection "google") = Right Nothing + | otherwise = do + gPackageName <- required "google" "package_name" + gServiceAccountFile <- T.unpack <$> required "google" "service_account_file" + pure (Just PlayStoreConfig {gPackageName, gServiceAccountFile, gApiHost = defaultPlayApiHost, gTokenUrl = defaultPlayTokenUrl}) sessionMinutes = do v <- num "stripe" "session_minutes" defaultSessionMinutes if v >= minSessionMinutes && v <= maxSessionMinutes diff --git a/apps/simplex-badge-service/src/BadgeService/Poller.hs b/apps/simplex-badge-service/src/BadgeService/Poller.hs index c22df44ddb..989faa4396 100644 --- a/apps/simplex-badge-service/src/BadgeService/Poller.hs +++ b/apps/simplex-badge-service/src/BadgeService/Poller.hs @@ -30,7 +30,7 @@ where import BadgeService.Config (PollConfig (..)) import BadgeService.Orders (decide, settleOrder) import BadgeService.Providers (ListPass (..), PaymentSignal (..), Provider (..), ProviderError (..), Received (..), expiresItself, settleWindow) -import BadgeService.Store.Invoices (InvoiceRow (..), OverdueInvoice (..), expireOverdue, getInvoiceByProviderRef, overdueInvoices, providerText, unpaidRefs) +import BadgeService.Store.Invoices (InvoiceRow (..), OverdueInvoice (..), expireOverdue, getInvoiceByProviderRef, overdueInvoices, unpaidRefs) import BadgeService.Waiters (Waiters, publish, waitingCount, waitingCountSTM) import Control.Concurrent.STM import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try) @@ -47,6 +47,7 @@ import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCu import Numeric.Natural (Natural) import Simplex.Chat.PaymentService.Types (InvoiceStatus (..), PaymentProvider (..)) import Simplex.Messaging.Agent.Store.Common (DBStore) +import Simplex.Messaging.Encoding.String (textEncode) import Simplex.Messaging.Util (tshow) -- | Allows for our clock running ahead of the provider's; an expired invoice can still be marked paid. @@ -145,7 +146,7 @@ readsPerPass = 25 coveringProvider :: PollerEnv -> UTCTime -> (Text, Text) -> IO (Maybe Provider) coveringProvider env@PollerEnv {peProviders} now (provider, ref) = - case find ((== provider) . providerText . pProvider) peProviders of + case find ((== provider) . textEncode . pProvider) peProviders of Just p -> pure (Just p) Nothing -> do due <- dueToWarn env now ("no provider for " <> provider) @@ -309,7 +310,7 @@ reportStrangers env provider now = \case home = case provider of PPCrypto -> "BTCPay store" PPStripe -> "Stripe account" - other -> providerText other <> " account" + other -> textEncode other <> " account" dueToWarn :: PollerEnv -> UTCTime -> Text -> IO Bool dueToWarn PollerEnv {peSkipped} now reason = atomically $ do diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 0e189a3489..72389c8cc7 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -7,22 +7,26 @@ module BadgeService.Service ( ServiceState (..), newServiceState, + configureStoreVerifier, welcomeGetOpts, checkIssuerKey, badgeService, badgeServiceCLI, badgeServiceResponse, badgeErrorRetryAfter, + shownServiceRequest, + survive, IssueCodeOpts (..), issueBadgeCode, ) where -import BadgeService.Catalog (defaultCatalog) +import BadgeService.Catalog (StoreProduct (..), defaultCatalog, storeProduct) import BadgeService.Codes (issueFailedText, issueOneCode, singleUse) -import BadgeService.Config (BadgeIssuerKey (..), ServiceConfig (..), readServiceConfig) +import BadgeService.Config (AppleStoreConfig (..), BadgeIssuerKey (..), PlayStoreConfig (..), ServiceConfig (..), readServiceConfig) import BadgeService.Group (GroupEvent, ensureManagedGroup, groupEvent, hasTracker, refreshTracker, revokeWithTracker, runGroupLane) import BadgeService.Group.Command (badgeTypeP, codeP, maxMonths, textTokenP) +import BadgeService.Log (logError, logInfo, logWarn) import BadgeService.Options import BadgeService.Poller (newPollerEnv, newReadHints, runPoller) import BadgeService.Providers.BTCPay (btcpayProvider) @@ -30,11 +34,14 @@ import BadgeService.Providers.Stripe (stripeProvider) import BadgeService.Store import BadgeService.Store.Invoices (seedCatalog, truncateToSecond) import BadgeService.Store.Migrate (runBadgeServiceMigrations) +import BadgeService.StoreReceipts +import BadgeService.StoreReceipts.Apple (readAppleRoot, verifyAppleTransaction) +import BadgeService.StoreReceipts.Google (playStoreVerifier) +import BadgeService.StoreReceipts.Mock (mockStoreVerifier) import BadgeService.Waiters (Waiters, newWaiters) import BadgeService.Web.Server (exportWebapp, newWebEnv, runWebListener) import Control.Applicative (optional) import Control.Concurrent.STM -import BadgeService.Log (logError, logInfo) import Control.Monad import Control.Monad.IO.Class (liftIO) import qualified Data.Aeson as J @@ -43,6 +50,7 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import Data.Either (fromRight) import Data.Functor (($>), (<&>)) +import Data.Int (Int64) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, maybeToList) import qualified Data.Text as T @@ -58,14 +66,17 @@ import Simplex.Chat.Bot.Store (withDB, withDB') import Simplex.Chat.Controller import Simplex.Chat.Core (sendChatCmd, simplexChatCore) import Simplex.Chat.Options (printDbOpts) +import Simplex.Chat.PaymentService (ServicePayment (..)) +import Simplex.Chat.PaymentService.Types (StoreTransactionRef (..)) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types (AgentInvId (..), User (..)) import Simplex.Messaging.Agent.Store.Common (DBStore) +import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (bbsPublicKey) import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (catchOwn', raceAny_, safeDecodeUtf8, tshow) import Simplex.Messaging.Version (isCompatible) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) @@ -73,15 +84,18 @@ import System.Exit (exitFailure) data ServiceState = ServiceState { serviceCC :: TMVar ChatController, serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object), - groupEventQ :: TQueue GroupEvent + groupEventQ :: TQueue GroupEvent, + storeVerifier :: StoreVerifier } +-- | With no store verifier, which badge_service.ini configures, every store receipt not already credited +-- is answered provider_not_configured. newServiceState :: IO ServiceState newServiceState = do serviceCC <- newEmptyTMVarIO serviceRequestQ <- newTQueueIO groupEventQ <- newTQueueIO - pure ServiceState {serviceCC, serviceRequestQ, groupEventQ} + pure ServiceState {serviceCC, serviceRequestQ, groupEventQ, storeVerifier = noStoreVerifier} welcomeGetOpts :: IO BadgeServiceOpts welcomeGetOpts = do @@ -120,11 +134,37 @@ readConfigOrExit path = Left e -> putStrLn (path <> ": " <> e) >> exitFailure Right sc -> pure sc +-- | The config refuses the dev flag beside a real verifier, so only one of them applies. +configureStoreVerifier :: Maybe ServiceConfig -> ServiceState -> IO ServiceState +configureStoreVerifier serviceCfg env@ServiceState {storeVerifier = v} = case serviceCfg of + Just ServiceConfig {devAcceptUnverifiedStoreReceipts = True} -> do + logWarn "[dev] accept_unverified_store_receipts is on: any store receipt is accepted without verification" + pure env {storeVerifier = mockStoreVerifier} + Just ServiceConfig {appleStore, playStore} -> do + apple <- forM appleStore $ \AppleStoreConfig {aBundleId, aRootCertificates} -> + (\roots -> verifyAppleTransaction roots aBundleId) <$> mapM (orExit . readAppleRoot) aRootCertificates + google <- forM playStore $ orExit . playStoreVerifier + forM_ appleStore $ \AppleStoreConfig {aBundleId} -> logInfo $ "verifying App Store receipts for " <> aBundleId + forM_ playStore $ \PlayStoreConfig {gPackageName} -> logInfo $ "verifying Play purchases for " <> gPackageName + pure + env + { storeVerifier = + v + { verifyApple = maybe (verifyApple v) Just apple, + verifyGoogle = maybe (verifyGoogle v) (Just . fst) google, + acknowledgeGoogle = maybe (acknowledgeGoogle v) (Just . snd) google + } + } + Nothing -> pure env + where + orExit = (>>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure) + badgeService :: BadgeServiceOpts -> ChatConfig -> ServiceState -> IO () badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do serviceCfg <- traverse readConfigOrExit serviceConfigFile key <- requireIssuerKey opts serviceCfg cfg waiters <- newWaiters + requestEnv <- configureStoreVerifier serviceCfg env let groupCfg = serviceCfg >>= \ServiceConfig {group} -> group trackerQ_ = groupEventQ env <$ groupCfg readEvents cc = @@ -139,7 +179,7 @@ badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do lanes <- maybe (pure []) (serviceLanes waiters cc) serviceCfg -- The group is resolved before the other lanes start, so a failed lookup exits at once rather than waiting for them to end. groupLane_ <- forM groupCfg $ \gc -> runGroupLane cc (groupEventQ env) <$> ensureManagedGroup cc gc - raceAny_ $ processQueuedRequests key trackerQ_ env : maybeToList groupLane_ <> lanes + raceAny_ $ processQueuedRequests key trackerQ_ requestEnv : maybeToList groupLane_ <> lanes chatHooks = defaultChatHooks { preStartHook = Just $ badgePreStartHook opts, @@ -175,13 +215,12 @@ badgeServiceCLI :: BadgeServiceOpts -> IO () badgeServiceCLI opts@BadgeServiceOpts {serviceConfigFile} = do serviceCfg <- traverse readConfigOrExit serviceConfigFile key <- requireIssuerKey opts serviceCfg terminalChatConfig - env <- newServiceState - let eventHook _cc ev = do - case ev of - Right (CEvtServiceRequest u reqId sigKey reqData) -> - atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) - _ -> pure () - pure ev + env <- newServiceState >>= configureStoreVerifier serviceCfg + let eventHook _cc = \case + Right (CEvtServiceRequest u reqId sigKey reqData) -> do + atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) + pure $ Right $ CEvtServiceRequest u reqId sigKey (shownServiceRequest reqData) + ev -> pure ev chatHooks = defaultChatHooks { preStartHook = Just $ badgePreStartHook opts, @@ -194,6 +233,12 @@ badgeServiceCLI opts@BadgeServiceOpts {serviceConfigFile} = do processQueuedRequests key Nothing env ] +-- | The terminal prints each request, and requests carry codes and store receipts, which are bearer secrets. +shownServiceRequest :: J.Object -> J.Object +shownServiceRequest reqData = case KM.lookup "request" reqData of + Just (J.Object cmd) | Just t <- KM.lookup "type" cmd -> KM.singleton "request" $ J.object ["type" J..= t] + _ -> KM.empty + badgeCmdHook :: ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand) badgeCmdHook cc = \case CustomChatCommand cmd -> Left <$> runBadgeCmd cc cmd @@ -244,9 +289,27 @@ issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} = processQueuedRequests :: BadgeIssuerKey -> Maybe (TQueue GroupEvent) -> ServiceState -> IO () processQueuedRequests key trackerQ_ env = do cc <- atomically $ readTMVar $ serviceCC env - forever $ do - (u, reqId, sigKey, reqData) <- atomically $ readTQueue $ serviceRequestQ env - handleServiceRequest key cc trackerQ_ u reqId sigKey reqData + -- a Play hand-over can wait two verifyTimeouts on Play, so it waits on a lane of its own + playQ <- newTQueueIO + let answer (u, reqId, sigKey, reqData) = handleServiceRequest key (storeVerifier env) cc trackerQ_ u reqId sigKey reqData + route req@(_, _, _, reqData) + | presentsPlayReceipt reqData = atomically $ writeTQueue playQ req + | otherwise = answer req + lane q act = forever $ do + req@(_, reqId, _, _) <- atomically $ readTQueue q + survive ("request " <> safeDecodeUtf8 (strEncode reqId)) $ act req + raceAny_ [lane (serviceRequestQ env) route, lane playQ answer] + +-- | Only what creates a purchase leaves the main lane: an append to an existing ledger is guarded by nothing but that lane being serial. +presentsPlayReceipt :: J.Object -> Bool +presentsPlayReceipt reqData = case J.fromJSON (J.Object reqData) of + J.Success BadgeServiceRequest {request = BSCPurchaseBadge {payment = SPGoogle {}, upgrade = Nothing}} -> True + _ -> False + +-- | Cancellation is rethrown, since that is how the race stops this thread. The +-- exception itself is not logged: it can quote the request, which carries codes and store receipts. +survive :: T.Text -> IO () -> IO () +survive what action = action `catchOwn'` \_ -> logError $ "badge service: " <> what <> " failed; the next one will be handled" badgePreStartHook :: BadgeServiceOpts -> ChatController -> IO () badgePreStartHook opts ChatController {config, chatStore} = @@ -263,11 +326,13 @@ badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc void $ atomically $ tryPutTMVar (serviceCC env) cc -handleServiceRequest :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () -handleServiceRequest key cc trackerQ_ User {userId} reqId sigKey reqData = do +handleServiceRequest :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () +handleServiceRequest key verifier cc trackerQ_ User {userId} reqId sigKey reqData = do let reqIdT = safeDecodeUtf8 (strEncode reqId) logInfo $ "badge service request " <> reqIdT - resp <- badgeServiceResponse key cc trackerQ_ sigKey reqData + resp <- + badgeServiceResponse key verifier cc trackerQ_ sigKey reqData `catchOwn'` \_ -> + logError ("badge service request " <> reqIdT <> " failed") $> errorResponse BSEInternal sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case Right _ -> pure () Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e @@ -289,8 +354,8 @@ badgeErrorRetryAfter = \case -- | The agent verified the signature, so sigKey is a key the sender holds; a differing purchaseKey would let a client claim a purchase it cannot sign for. -badgeServiceResponse :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse -badgeServiceResponse key cc trackerQ_ sigKey reqData = case J.fromJSON (J.Object reqData) of +badgeServiceResponse :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse +badgeServiceResponse key verifier cc trackerQ_ sigKey reqData = case J.fromJSON (J.Object reqData) of J.Error _ -> pure $ errorResponse BSEBadRequest J.Success BadgeServiceRequest {version, purchaseKey, request} | not (version `isCompatible` supportedBadgeServiceVRange) -> pure $ errorResponse BSEUnsupportedVersion @@ -299,10 +364,16 @@ badgeServiceResponse key cc trackerQ_ sigKey reqData = case J.fromJSON (J.Object BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of Just k -> redeemCode key cc trackerQ_ k masterKey code Nothing -> pure $ errorResponse BSEBadRequest + BSCPurchaseBadge {masterKey, payment, upgrade} + | Just receipt_ <- toStoreReceipt verifier payment -> case (purchaseKey, upgrade) of + (Just k, Nothing) -> either storeRefusalResponse (purchaseWithReceipt key cc k masterKey) receipt_ + -- store upgrades are not built, and ignoring one would credit its months at the discounted price + (Just _, Just _) -> pure $ errorResponse BSEBadRequest + (Nothing, _) -> pure $ errorResponse BSEBadRequest BSCIssueBadge {balance} -> case purchaseKey of Just k -> issueBadgeCmd key cc k balance Nothing -> pure $ errorResponse BSEBadRequest - -- Every command but redeemBadgeCode needs a key the service already knows; that one creates the purchase, so its key is unknown on a first redemption. + -- Every command but redeemBadgeCode and purchaseBadge from a store needs a key the service already knows; those two create the purchase, so their key is unknown on a first purchase. _ -> case purchaseKey of Nothing -> pure $ errorResponse BSEUnsupportedVersion Just k -> @@ -339,31 +410,21 @@ redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode withDB "getBadgeCode" cc (readCode now code) >>= \case Left _ -> pure $ errorResponse BSEInternal Right (Left resp) -> pure resp - Right (Right IssuedCode {badgeCodeId, badgeType, months, redeemLimit}) -> do - (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc - -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the - -- months it adds are counted from a start already in the past - let granted = grantEntry now grantUuid months SCCode $ emptyEntry now badgeType - -- A grant of at least one month starting now always has a month to issue. - case issueEntry now issueUuid granted of - Nothing -> pure $ errorResponse BSEInternal - Just issued -> credentialForEntry key masterKey issued >>= \case - Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal - Right signed -> do - -- If the code was revoked or used up while signing, the claim fails. Read the code again to tell the client why. - r <- withDB "writeCodeRedemption" cc $ \db -> - liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case - Nothing -> - readCode now code db >>= \case - Left resp -> pure (resp, False) - Right _ -> logError "badge service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse BSEInternal, False) - Just purchaseId -> liftIO $ do - appendLedgerPlan db purchaseId [granted] $ Just $ issuanceAfter granted signed - entries_ <- getLedgerEntries db purchaseId 0 - pure (maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_, True) - let (resp, claimed) = fromRight (errorResponse BSEInternal, False) r - when (claimed && hasTracker redeemLimit) $ refreshTracker cc trackerQ_ badgeCodeId code - pure resp + Right (Right IssuedCode {badgeCodeId, badgeType, months, redeemLimit}) -> + signFirstMonth key cc masterKey badgeType months SCCode now >>= \case + Left resp -> pure resp + Right firstMonth -> do + -- If the code was revoked or used up while signing, the claim fails. Read the code again to tell the client why. + r <- withDB "writeCodeRedemption" cc $ \db -> + liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case + Nothing -> + readCode now code db >>= \case + Left resp -> pure (resp, False) + Right _ -> logError "badge service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse BSEInternal, False) + Just purchaseId -> liftIO $ (,True) <$> firstMonthResponse db purchaseId Nothing firstMonth + let (resp, claimed) = fromRight (errorResponse BSEInternal, False) r + when (claimed && hasTracker redeemLimit) $ refreshTracker cc trackerQ_ badgeCodeId code + pure resp where readCode now code db = liftIO $ getBadgeCode db (badgeCodeHash code) >>= \case @@ -387,6 +448,99 @@ redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode | maybe False (now >=) expiresAt -> pure $ Left $ errorResponse BSECodeExpired | otherwise -> pure $ Right c +-- | Every refusal is answered before anything is written, so it leaves the receipt unclaimed; and +-- nothing is written until the credential is signed. +purchaseWithReceipt :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> StoreReceipt -> IO BadgeServiceResponse +purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {txRef = StoreTransactionRef {provider, transactionRef = providerRef}, verifyReceipt, acknowledgeReceipt} = + withDB' "getStorePayment" cc (\db -> getStorePaymentCredit db provider providerRef) >>= \case + Left _ -> pure $ errorResponse BSEInternal + Right credit + -- only the key it credited can ask, and it is told only what it was given, so the store is not asked + | creditedBy credit -> answerCredit credit + | otherwise -> + verifyReceipt >>= \case + Left refusal -> storeRefusalResponse refusal + -- the claim was read from the evidence before it was verified, so it must name the transaction the store vouched for + Right VerifiedStoreTransaction {transactionRef} + | transactionRef /= providerRef -> storeRefusalResponse $ SRVerifierFailed "verified a transaction other than the one claimed" + Right VerifiedStoreTransaction {quantity} + | quantity /= 1 -> storeRefusalResponse $ SRVerifierFailed $ "verified a quantity of " <> tshow quantity + Right VerifiedStoreTransaction {testPurchase = True} -> storeRefusalResponse SRTestPurchase + -- another key's credit is told only once the store vouched for the receipt, or it would reveal which transactions were credited + Right tx -> case credit of + Uncredited -> newPurchase tx + _ -> answerCredit credit + where + creditedBy = \case + Credited CreditedPurchase {purchaseKey = k} -> k == purchaseKey + _ -> False + answerCredit credit = + withDB' "answerStoreCredit" cc (\db -> creditedResponse db purchaseKey credit) <&> \case + Right (Left resp) -> resp + _ -> errorResponse BSEInternal + -- the product is read only for a receipt not yet credited, so retiring it leaves its replays answered + newPurchase tx@VerifiedStoreTransaction {productId, paid} = case storeProduct provider productId of + Nothing -> pure $ errorResponse BSEProductUnavailable + Just StoreProduct {badgeType, months} -> + maybe (pure $ Right ()) ($ tx) acknowledgeReceipt >>= \case + Left refusal -> storeRefusalResponse refusal + Right () -> do + now <- badgeNow cc + signFirstMonth key cc masterKey badgeType months (SCPayment Nothing) now >>= \case + Left resp -> pure resp + Right firstMonth -> do + paymentId <- randomId cc + r <- withDB "writeStorePurchase" cc $ \db -> + liftIO (createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey, badgeType} now) >>= \case + -- Credited to another key, or to this one by a request that ran alongside it, while signing. + Nothing -> + liftIO (getStorePaymentCredit db provider providerRef >>= creditedResponse db purchaseKey) >>= \case + Left resp -> pure resp + Right () -> logError ("badge service: a payment for " <> providerRef <> " exists but funds no purchase") $> errorResponse BSEInternal + Just purchaseId -> liftIO $ firstMonthResponse db purchaseId (Just paymentId) firstMonth + pure $ fromRight (errorResponse BSEInternal) r + +-- | A forged receipt and a refunded one are both receipt_invalid, so the client learns nothing a +-- forger could iterate on. A test purchase is named, since the device that made it already knows. +storeRefusalResponse :: StoreRefusal -> IO BadgeServiceResponse +storeRefusalResponse = \case + SRInvalid reason -> logInfo ("store receipt refused: " <> reason) $> errorResponse BSEReceiptInvalid + SRTestPurchase -> logInfo "store receipt refused: test purchase" $> errorResponse BSETestPurchase + SRPending -> pure $ errorResponse BSEPaymentPending + SRUnreachable reason -> logWarn ("store unreachable: " <> reason) $> errorResponse BSEProviderUnavailable + SRVerifierFailed reason -> logError ("store receipt not verified: " <> reason) $> errorResponse BSEInternal + SRNotConfigured -> logWarn "store receipt refused: no verifier for this store is configured" $> errorResponse BSEProviderNotConfigured + +creditedResponse :: DB.Connection -> C.PublicKeyEd25519 -> FundingCredit -> IO (Either BadgeServiceResponse ()) +creditedResponse db purchaseKey = \case + Uncredited -> pure $ Right () + CreditedUnreadable -> pure $ Left $ errorResponse BSEInternal + Credited CreditedPurchase {purchaseKey = k, badgePurchaseId, credential} + | k /= purchaseKey -> pure $ Left $ errorResponse BSEReceiptUsed + | otherwise -> + maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) + <$> getLedgerEntries db badgePurchaseId 0 + +signFirstMonth :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> BadgeType -> Int -> StatementCreditType -> UTCTime -> IO (Either BadgeServiceResponse (StatementEntry, (StatementEntry, BadgeCredential))) +signFirstMonth key cc masterKey badgeType months credit now = do + (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc + -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the + -- months it adds are counted from a start already in the past + let granted = grantEntry now grantUuid months credit $ emptyEntry now badgeType + -- A grant of at least one month starting now always has a month to issue. + case issueEntry now issueUuid granted of + Nothing -> pure $ Left $ errorResponse BSEInternal + Just issued -> + credentialForEntry key masterKey issued >>= \case + Left e -> logError ("badge service signing failed: " <> T.pack e) $> Left (errorResponse BSEInternal) + Right signed -> pure $ Right (granted, signed) + +firstMonthResponse :: DB.Connection -> Int64 -> Maybe T.Text -> (StatementEntry, (StatementEntry, BadgeCredential)) -> IO BadgeServiceResponse +firstMonthResponse db purchaseId creditPaymentId_ (granted, signed) = do + appendLedgerPlan db purchaseId creditPaymentId_ [granted] $ Just $ issuanceAfter granted signed + entries_ <- getLedgerEntries db purchaseId 0 + pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_ + -- | The purchase is reached through the verified signer key and no other way. issueBadgeCmd :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeBalance -> IO BadgeServiceResponse issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do @@ -414,7 +568,7 @@ issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do writeIssued purchaseId tip rows t issuance_ = do r <- withDB "issueBadge" cc $ \db -> liftIO $ do tip' <- getLedgerTip db purchaseId - when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId rows issuance_ + when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId Nothing rows issuance_ issueResponse db purchaseId t pure $ fromRight (errorResponse BSEInternal) r -- Only the asserted entry's identity is read, never the months it claims. diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs index b51e47bdad..6d238e78f4 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store.hs @@ -4,12 +4,16 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeOperators #-} module BadgeService.Store ( IssuedCode (..), KeyRedemption (..), KeyPurchase (..), + FundingCredit (..), + CreditedPurchase (..), NewCodePurchase (..), + NewStorePurchase (..), ServicePurchase (..), ManagedGroup (..), getManagedGroup, @@ -18,6 +22,7 @@ module BadgeService.Store markOwnerBootstrapped, getBadgeCode, getCodePurchaseForKey, + getStorePaymentCredit, purchaseKeyExists, getPurchaseByKey, getLedgerTip, @@ -26,6 +31,7 @@ module BadgeService.Store getCurrentIssuance, appendLedgerPlan, createCodePurchase, + createStorePurchase, insertBadgeCode, setCodeGroupItem, CodeTracker (..), @@ -36,7 +42,7 @@ module BadgeService.Store ) where -import BadgeService.Store.Invoices (executeChanging) +import BadgeService.Store.Invoices (executeChanging, paymentStatusText) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB @@ -46,12 +52,14 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType) import Simplex.Chat.Badges.Ledger -import Simplex.Chat.Badges.Service (StatementEntry (..)) +import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementEntry (..), StatementEntryType (..)) import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..)) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), InvoiceId (..), PaymentProvider, PaymentStatus (..)) import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (textEncode) import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow') #if defined(dbPostgres) @@ -83,6 +91,17 @@ data KeyPurchase = KeyPurchase credential :: BadgeCredential } +data FundingCredit + = Uncredited + | Credited CreditedPurchase + | CreditedUnreadable + +data CreditedPurchase = CreditedPurchase + { badgePurchaseId :: Int64, + purchaseKey :: C.PublicKeyEd25519, + credential :: BadgeCredential + } + data NewCodePurchase = NewCodePurchase { badgeCodeId :: Int64, purchaseKey :: C.PublicKeyEd25519, @@ -90,6 +109,16 @@ data NewCodePurchase = NewCodePurchase badgeType :: BadgeType } +data NewStorePurchase = NewStorePurchase + { paymentId :: Text, + provider :: PaymentProvider, + providerRef :: Text, + paid :: Maybe (CurrencyAmount, Text), + purchaseKey :: C.PublicKeyEd25519, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType + } + data ServicePurchase = ServicePurchase { badgePurchaseId :: Int64, masterKey :: BadgeMasterKey, @@ -171,7 +200,29 @@ getCodePurchaseForKey db badgeCodeId key = toRedemption (badgePurchaseId, credential_) = case decodeCredential =<< credential_ of Just credential -> KeyRedeemed KeyPurchase {badgePurchaseId, credential} Nothing -> KeyRedeemedUnreadable - decodeCredential (Binary bs) = J.decodeStrict' bs + +getStorePaymentCredit :: DB.Connection -> PaymentProvider -> Text -> IO FundingCredit +getStorePaymentCredit db provider providerRef = + maybeFirstRow' Uncredited toCredit $ + DB.query + db + [sql| + SELECT p.badge_purchase_id, p.purchase_key, i.credential + FROM sx_badge_service_payments pay + JOIN sx_badge_service_badge_purchases p ON p.payment_id = pay.payment_id + LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id + WHERE pay.provider = ? AND pay.provider_ref = ? + ORDER BY i.period_end DESC + LIMIT 1 + |] + (textEncode provider, providerRef) + where + toCredit (badgePurchaseId, purchaseKey, credential_) = case decodeCredential =<< credential_ of + Just credential -> Credited CreditedPurchase {badgePurchaseId, purchaseKey, credential} + Nothing -> CreditedUnreadable + +decodeCredential :: Binary ByteString -> Maybe BadgeCredential +decodeCredential (Binary bs) = J.decodeStrict' bs purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool purchaseKeyExists db key = @@ -199,11 +250,12 @@ getLedgerTip db purchaseId = DB.query db [sql| - SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, - entry_type, entry_credit_type, entry_debit_type, service_created_at - FROM sx_badge_service_badge_ledger - WHERE badge_purchase_id = ? - ORDER BY entry_id DESC + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.entry_type, l.entry_credit_type, l.entry_debit_type, l.service_created_at, l.payment_id, pay.invoice_id + FROM sx_badge_service_badge_ledger l + LEFT JOIN sx_badge_service_payments pay ON pay.payment_id = l.payment_id + WHERE l.badge_purchase_id = ? + ORDER BY l.entry_id DESC LIMIT 1 |] (Only purchaseId) @@ -223,18 +275,19 @@ getLedgerEntries db purchaseId afterEntryId = <$> DB.query db [sql| - SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, - entry_type, entry_credit_type, entry_debit_type, service_created_at - FROM sx_badge_service_badge_ledger - WHERE badge_purchase_id = ? AND entry_id > ? - ORDER BY entry_id + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.entry_type, l.entry_credit_type, l.entry_debit_type, l.service_created_at, l.payment_id, pay.invoice_id + FROM sx_badge_service_badge_ledger l + LEFT JOIN sx_badge_service_payments pay ON pay.payment_id = l.payment_id + WHERE l.badge_purchase_id = ? AND l.entry_id > ? + ORDER BY l.entry_id |] (purchaseId, afterEntryId) -toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) -> Maybe StatementEntry -toEntry (entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) = +toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) :. (Maybe Text, Maybe Text) -> Maybe StatementEntry +toEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) :. (paymentId_, invoiceId_)) = (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince = Nothing, createdAt, entryType}) - <$> entryTypeFromColumns entryType_ credit_ debit_ + <$> entryTypeFromColumns ((InvoiceId <$> invoiceId_) <$ paymentId_) entryType_ credit_ debit_ getCurrentIssuance :: DB.Connection -> Int64 -> UTCTime -> IO (Maybe BadgeCredential) getCurrentIssuance db purchaseId now = do @@ -252,9 +305,10 @@ getCurrentIssuance db purchaseId now = do [Only (Binary bs)] -> J.decodeStrict' bs _ -> Nothing --- TODO write the reference columns (payment_id, charge_id, from_purchase_id, to_purchase_id) for entry types that carry one; only the tag is written today. -appendLedgerPlan :: DB.Connection -> Int64 -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO () -appendLedgerPlan db purchaseId rows issuance_ = do +-- | creditPaymentId_ names the payment behind a payment credit among the rows. +-- TODO write the other reference columns (charge_id, from_purchase_id, to_purchase_id) for entry types that carry one; only the tag is written today. +appendLedgerPlan :: DB.Connection -> Int64 -> Maybe Text -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO () +appendLedgerPlan db purchaseId creditPaymentId_ rows issuance_ = do mapM_ appendRow rows case issuance_ of Nothing -> pure () @@ -273,15 +327,18 @@ appendLedgerPlan db purchaseId rows issuance_ = do where appendRow StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, createdAt, entryType} = do let (entryTypeT, creditType, debitType) = entryTypeColumns entryType + paymentId_ = case entryType of + SECredit SCPayment {} -> creditPaymentId_ + _ -> Nothing DB.execute db [sql| INSERT INTO sx_badge_service_badge_ledger (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, - balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type, payment_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType)) + ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType, paymentId_)) insertedRowId db -- The claim takes one use before adding the purchase, so a concurrent revoke or redemption waits on this row and sees the new count. @@ -306,6 +363,32 @@ createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = Bad (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now) Just <$> insertedRowId db +-- | In the transaction of the credential rows. The payment's unique (provider, provider_ref) is the +-- claim: a transaction presented twice at once, or under another key, inserts nothing the second time. +createStorePurchase :: DB.Connection -> NewStorePurchase -> UTCTime -> IO (Maybe Int64) +createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do + claimed <- + executeChanging + db + [sql| + INSERT INTO sx_badge_service_payments (payment_id, provider, provider_ref, amount, currency, status, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT (provider, provider_ref) DO NOTHING + |] + (paymentId, textEncode provider, providerRef, (\(CurrencyAmount a) -> a) . fst <$> paid, snd <$> paid, paymentStatusText PSSettled, now, now) + if claimed == 0 + then pure Nothing + else do + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_purchases + (purchase_key, master_key, initial_badge_type, current_badge_type, status, payment_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, paymentId, now, now) + Just <$> insertedRowId db + -- | Revoked and AlreadyRevoked carry the code id, so the caller can retire the code's group tracker, -- or repair one an earlier revoke left live. data RevokeResult = Revoked Int64 | AlreadyRevoked Int64 | AlreadyRedeemed | NoSuchCode diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs b/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs index a3e177400e..57f9341308 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Invoices.hs @@ -17,7 +17,6 @@ module BadgeService.Store.Invoices getInvoice, getInvoiceByProviderRef, unpaidRefs, - providerText, codeHashExists, OverdueInvoice (..), overdueInvoices, @@ -59,7 +58,7 @@ import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Chat.PaymentService.Types (CardProvider (..), CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), PaymentStatus (..), ServicePaymentDestination (..)) import Simplex.Messaging.Agent.Store.Common (DBStore, withConnection, withTransaction) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Encoding.String (textEncode) +import Simplex.Messaging.Encoding.String (textDecode, textEncode) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) #if defined(dbPostgres) @@ -290,25 +289,6 @@ qSeedBadgeOffer = "INSERT INTO @badge_offers (offer_id, price_id, months, free_months, discount, status, created_at) " <> "VALUES (?,?,?,?,?,?,?) ON CONFLICT (offer_id) DO NOTHING" -providerText :: PaymentProvider -> Text -providerText = \case - PPApple -> "apple" - PPGoogle -> "google" - PPStripe -> "stripe" - PPCrypto -> "crypto" - PPCode -> "code" - PPReceipt -> "receipt" - -textToProvider :: Text -> Maybe PaymentProvider -textToProvider = \case - "apple" -> Just PPApple - "google" -> Just PPGoogle - "stripe" -> Just PPStripe - "crypto" -> Just PPCrypto - "code" -> Just PPCode - "receipt" -> Just PPReceipt - _ -> Nothing - cryptoCurrencyText :: CryptoCurrency -> Text cryptoCurrencyText CCBtc = "btc" cryptoCurrencyText CCXmr = "xmr" @@ -380,7 +360,7 @@ mkInvoiceRow :. (cryptoAmt, expiresAt, statusTxt, createdAt) :. (pAmount, pCryptoPaid, pCryptoDue, pPaidInFull, pStatus, pUpdatedAt) ) = do - provider <- note "invoices.provider" (textToProvider providerTxt) + provider <- note "invoices.provider" (textDecode providerTxt) status <- note "invoices.status" (textToInvoiceStatus statusTxt) destination <- note "invoice payment destination" (mkDestination url addr cryptoCur cryptoAmt) pure @@ -462,7 +442,7 @@ insertInvoiceRows db NewInvoice {..} = do DB.execute db qInsertInvoice - ( (invId, providerText niProvider, price, discountAmount, Nothing :: Maybe Word32, amount, niCurrency) + ( (invId, textEncode niProvider, price, discountAmount, Nothing :: Maybe Word32, amount, niCurrency) :. (url, addr, cryptoCur, cryptoAmt, expiresAt, invoiceStatusText ISOpen, createdAt, createdAt) ) DB.execute @@ -526,7 +506,7 @@ overdueInvoices st cutoff = withConnection st $ \db -> do rows <- DB.query db qOverdueInvoices (Only (truncateToSecond cutoff)) either (E.throwIO . StoreDecodeError) pure (traverse toOverdue rows) where - toOverdue (i, providerTxt, oiProviderRef, oiCreatedAt) = case textToProvider providerTxt of + toOverdue (i, providerTxt, oiProviderRef, oiCreatedAt) = case textDecode providerTxt of Just oiProvider -> Right OverdueInvoice {oiInvoiceId = InvoiceId i, oiProvider, oiProviderRef, oiCreatedAt} Nothing -> Left ("invoices.provider: " <> providerTxt) @@ -591,7 +571,7 @@ upsertPayment db InvoiceRow {irInvoiceId, irProvider, irProviderRef, irCurrency} DB.execute db qUpsertPayment - ( (invId, invId, providerText irProvider, irProviderRef, amount) + ( (invId, invId, textEncode irProvider, irProviderRef, amount) :. (irCurrency, cryptoAmount, cryptoDue, if paidInFull then 1 :: Int else 0, paymentStatusText status, at, at) ) where diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs index f096d3098d..92d3b5fd47 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -18,7 +18,8 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations schemaMigrations :: [(String, Text, Maybe Text)] schemaMigrations = [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema), - ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops) + ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops), + ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) ] -- | The client tables share this database, so the service tables are the same names behind a prefix. @@ -152,6 +153,44 @@ ALTER TABLE @badge_codes DROP COLUMN redeem_limit; DROP TABLE @group; |] +m20260925_badge_store_receipts :: Text +m20260925_badge_store_receipts = + withPrefix + servicePrefix + -- The unique provider_ref is what makes one store transaction fund one purchase: presented twice + -- at once, or under two keys, only one insert of its payment succeeds. + [r| +DROP INDEX @idx_payments_provider_ref; + +CREATE UNIQUE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); + +ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); + +ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); +|] + +down_m20260925_badge_store_receipts :: Text +down_m20260925_badge_store_receipts = + withPrefix + servicePrefix + [r| +DROP INDEX @idx_badge_ledger_payment; + +ALTER TABLE @badge_ledger DROP COLUMN payment_id; + +DROP INDEX @idx_badge_purchases_payment; + +ALTER TABLE @badge_purchases DROP COLUMN payment_id; + +DROP INDEX @idx_payments_provider_ref; + +CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); +|] + {- TODO [badges] deferred with the draft in M20260915_user_badges, service only. ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs index cd89859686..cd3d97f0ac 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -19,7 +19,8 @@ badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations = [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema), - ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops) + ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops), + ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) ] -- | The client tables share this database, so the service tables are the same names behind a prefix. @@ -153,6 +154,44 @@ ALTER TABLE @badge_codes DROP COLUMN redeem_limit; DROP TABLE @group; |] +m20260925_badge_store_receipts :: Query +m20260925_badge_store_receipts = + withPrefix + servicePrefix + -- The unique provider_ref is what makes one store transaction fund one purchase: presented twice + -- at once, or under two keys, only one insert of its payment succeeds. + [sql| +DROP INDEX @idx_payments_provider_ref; + +CREATE UNIQUE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); + +ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); + +ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); +|] + +down_m20260925_badge_store_receipts :: Query +down_m20260925_badge_store_receipts = + withPrefix + servicePrefix + [sql| +DROP INDEX @idx_badge_ledger_payment; + +ALTER TABLE @badge_ledger DROP COLUMN payment_id; + +DROP INDEX @idx_badge_purchases_payment; + +ALTER TABLE @badge_purchases DROP COLUMN payment_id; + +DROP INDEX @idx_payments_provider_ref; + +CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); +|] + {- TODO [badges] deferred with the draft in M20260915_user_badges, service only. ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs new file mode 100644 index 0000000000..2789e80c54 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs @@ -0,0 +1,129 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.StoreReceipts + ( VerifiedStoreTransaction (..), + StoreRefusal (..), + StoreVerifier (..), + StoreReceipt (..), + noStoreVerifier, + toStoreReceipt, + ) +where + +import Control.Exception (evaluate) +import Data.Char (isAlphaNum, isAscii, isAsciiLower, isAsciiUpper, isControl, isDigit, isPrint, isSpace) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Chat.PaymentService (ServicePayment (..), appleTransactionId, googlePurchaseRef) +import Simplex.Chat.PaymentService.Types (CurrencyAmount, PaymentProvider (..), StoreTransactionRef (..)) +import Simplex.Messaging.Util (catchOwn', tshow) +import System.Timeout (timeout) + +-- | What a store vouches for about one completed transaction. +-- TODO [badges] PaymentFunding's PFApple and PFGoogle sketch the same thing and have no producer; +-- delete them, or build them out and reconcile. +data VerifiedStoreTransaction = VerifiedStoreTransaction + { transactionRef :: Text, -- from what was verified: Apple's transactionId, googlePurchaseRef of the token asked about + productId :: Text, + quantity :: Int, -- as the store reported it; the service refuses any but 1, as the apps never buy more + -- costs the buyer nothing: Apple's Sandbox, which a public TestFlight build buys in, and Google's license testers + testPurchase :: Bool, + paid :: Maybe (CurrencyAmount, Text), -- in minor units; Google's purchase record carries no price + alreadyAcknowledged :: Bool -- Play's record says acknowledged or consumed; other stores have nothing to acknowledge + } + deriving (Eq, Show) + +-- | The reasons are for the service's log alone and must never quote the receipt. +-- SRInvalid and SRTestPurchase are terminal: the client drops the purchase's keys and nothing +-- presents it again. So only a verdict no later attempt could change is either; when in doubt, SRUnreachable. +data StoreRefusal + = SRInvalid Text -- a verdict that cannot change, and the client consumes the purchase: forged, malformed, refunded + | SRTestPurchase -- the store vouched for it, but it cost nothing; terminal, and named so the app can say why + | SRPending -- a real purchase the store has not settled; it may yet + | SRUnreachable Text -- no verdict: the store was not asked, did not answer, or does not know the token (a Play 404 may be lag) + | SRVerifierFailed Text -- this service's own failure, its bug or misconfiguration, never the store's answer + | SRNotConfigured -- no verifier for this store is deployed; the purchase may be real + deriving (Eq, Show) + +-- | Apple signs its receipt and ships the certificate chain in it, so it is verified with no network +-- call; a Play token is opaque and must be asked about, which is why only the Play fields are in IO. +data StoreVerifier = StoreVerifier + { -- the JWS + verifyApple :: Maybe (Text -> Either StoreRefusal VerifiedStoreTransaction), + verifyGoogle :: Maybe (Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction)), -- the product id and the token + acknowledgeGoogle :: Maybe (Text -> Text -> IO (Either StoreRefusal ())), + -- microseconds; a lane answers one request at a time, so a verifier that does not finish holds up the rest of its lane + verifyTimeout :: Int + } + +-- | A store payment, named by the store's own reference before anything is verified. +data StoreReceipt = StoreReceipt + { txRef :: StoreTransactionRef, + verifyReceipt :: IO (Either StoreRefusal VerifiedStoreTransaction), + acknowledgeReceipt :: Maybe (VerifiedStoreTransaction -> IO (Either StoreRefusal ())) + } + +noStoreVerifier :: StoreVerifier +noStoreVerifier = StoreVerifier {verifyApple = Nothing, verifyGoogle = Nothing, acknowledgeGoogle = Nothing, verifyTimeout = 10000000} + +-- | Nothing for a payment no store made. Exceptions are not logged, since they can quote the receipt +-- or, from Google, a URL holding the token. +toStoreReceipt :: StoreVerifier -> ServicePayment -> Maybe (Either StoreRefusal StoreReceipt) +toStoreReceipt StoreVerifier {verifyApple, verifyGoogle, acknowledgeGoogle, verifyTimeout} = \case + SPApple {jws} -> Just $ case appleTransactionId jws of + Nothing -> Left $ SRInvalid "names no transaction" + Just ref -> Right $ StoreReceipt (StoreTransactionRef PPApple ref) (maybe unconfigured (\verify -> offline $ verify jws) verifyApple) Nothing + SPGoogle {productId, token} + -- the claim is the token's hash, so neither string may name any purchase but the one it claims, + -- whatever path a verifier builds from them + | not (googleProductId productId) -> Just $ Left $ SRInvalid "not a Play product id" + -- Play documents no token grammar, so this is our guess, and refusing to ask Play is not its verdict + | not (googleToken token) -> Just $ Left $ SRUnreachable $ "a Play token this service will not send: " <> tokenShape token + | otherwise -> + Just $ Right $ + StoreReceipt + (StoreTransactionRef PPGoogle (googlePurchaseRef token)) + (maybe unconfigured (\verify -> online "google verifier" $ verify productId token) verifyGoogle) + ((\acknowledge VerifiedStoreTransaction {alreadyAcknowledged} -> if alreadyAcknowledged then pure (Right ()) else online "acknowledging: google" $ acknowledge productId token) <$> acknowledgeGoogle) + SPInvoice {} -> Nothing + SPReceipt {} -> Nothing + where + unconfigured = pure $ Left SRNotConfigured + -- nothing was fetched, so a throw or an overrun is this service's failure or a shape it does not know, never an outage + offline verdict = + (fromMaybe (Left $ SRVerifierFailed "apple verifier timed out") <$> timeout verifyTimeout (forced verdict)) + `catchOwn'` \_ -> pure $ Left $ SRVerifierFailed "apple verifier threw" + online what call = + (fromMaybe (Left $ SRUnreachable $ what <> " timed out") <$> timeout verifyTimeout (call >>= forced)) + `catchOwn'` \_ -> pure $ Left $ SRUnreachable $ what <> " threw" + -- a verdict holding a thunk that throws would otherwise throw later, outside these handlers + forced = either (fmap Left . evaluate) (fmap Right . evaluate) + +googleProductId :: Text -> Bool +googleProductId pid = case T.uncons pid of + Just (c, _) -> T.length pid <= 150 && (isAsciiLower c || isDigit c) && T.all (\x -> isAsciiLower x || isDigit x || x == '_' || x == '.') pid + Nothing -> False + +-- | Not empty or only dots, which a URL path resolves away. +googleToken :: Text -> Bool +googleToken t = not (T.all (== '.') t) && T.length t <= 4096 && T.all (\x -> isAsciiLower x || isAsciiUpper x || isDigit x || x == '.' || x == '_' || x == '-') t + +-- | What the log may say about a token: its length and the kinds of character in it, never any of them. +tokenShape :: Text -> Text +tokenShape t = tshow (T.length t) <> " characters: " <> T.intercalate ", " [kind | (kind, isKind) <- kinds, T.any isKind t] + where + kinds = + [ ("lowercase", isAsciiLower), + ("uppercase", isAsciiUpper), + ("digits", isDigit), + ("'.'", (== '.')), + ("'_'", (== '_')), + ("'-'", (== '-')), + ("other printable ASCII", \c -> isAscii c && isPrint c && not (isAlphaNum c) && c `notElem` ("._-" :: String)), + ("whitespace or control", \c -> isSpace c || isControl c), + ("non-ASCII", not . isAscii) + ] diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs new file mode 100644 index 0000000000..c94c6dec61 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs @@ -0,0 +1,189 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module BadgeService.StoreReceipts.Apple + ( verifyAppleTransaction, + readAppleRoot, + ) +where + +import BadgeService.StoreReceipts (StoreRefusal (..), VerifiedStoreTransaction (..)) +import Control.Exception (IOException, try) +import Control.Monad (guard, unless) +import Crypto.Hash.Algorithms (SHA256 (..)) +import Crypto.Number.Serialize (os2ip) +import qualified Crypto.PubKey.ECC.ECDSA as ECDSA +import qualified Crypto.PubKey.ECC.Types as ECC +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as JM +import qualified Data.Aeson.Types as JT +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Base64.URL as B64U +import Data.Foldable (toList) +import Data.List.NonEmpty (NonEmpty) +import Data.Maybe (isJust) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Word (Word32) +import Data.X509 +import Data.X509.EC (unserializePoint) +import Data.X509.Validation (SignatureFailure (..), SignatureVerification (..), verifySignedSignature) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) +import Simplex.Messaging.Util (eitherToMaybe) + +verifyAppleTransaction :: NonEmpty SignedCertificate -> Text -> Text -> Either StoreRefusal VerifiedStoreTransaction +verifyAppleTransaction roots ourBundleId jws = case T.splitOn "." jws of + [header, payload, signature] -> do + leaf <- signingChain roots =<< certificateChain header + sig <- jwsSignature signature + key <- leafKey leaf + unless (ECDSA.verify SHA256 key sig (encodeUtf8 $ header <> "." <> payload)) $ + refused "the signature does not verify" + tx <- either (const $ failed "the payload Apple signed is not a transaction") Right $ do + o <- maybe (Left "not base64url JSON") Right $ jsonObject payload + JT.parseEither appleTransactionP o + transaction ourBundleId tx + _ -> refused "not three dot-separated parts" + +-- | A header or chain in a shape Apple may yet adopt proves no forgery, so it fails rather than refuses. +certificateChain :: Text -> Either StoreRefusal [ByteString] +certificateChain header = do + o <- maybe (refused "the header is not base64url JSON") Right $ jsonObject header + case JM.lookup "alg" o of + Just (J.String "ES256") -> Right () + _ -> failed "the header does not name ES256" + case JM.lookup "x5c" o of + Just (J.Array certs) -> traverse der $ toList certs + _ -> failed "the header carries no x5c chain" + where + der = \case + J.String c | Right bs <- B64.decode (encodeUtf8 c) -> Right bs + _ -> refused "an x5c certificate is not base64" + +-- | The root in the chain is ignored: trust comes from the configured ones alone. +signingChain :: NonEmpty SignedCertificate -> [ByteString] -> Either StoreRefusal SignedCertificate +signingChain roots = \case + [leafDer, intermediateDer, _] -> do + leaf <- certificate leafDer + intermediate <- certificate intermediateDer + -- a root may be missing or wrong as likely as the receipt forged, so this retries rather than refuses + unless (any (`signedBy` intermediate) roots) $ failed "the intermediate is not signed by a configured root" + case verifySignedSignature leaf (certPubKey $ getCertificate intermediate) of + SignaturePass -> Right () + SignatureFailed SignatureInvalid -> refused "the leaf is not signed by the intermediate" + SignatureFailed _ -> failed "the intermediate's key cannot check the leaf's signature algorithm" + -- Apple issues other certificates under this root, developers' among them; only these two mark the receipt-signing chain + unless (marked receiptSigningLeaf leaf && marked appleIntermediate intermediate) $ + failed "Apple issued this chain, but not to sign receipts" + pure leaf + _ -> failed "x5c is not leaf, intermediate and root" + where + certificate = either (const $ failed "an x5c certificate does not decode") Right . decodeSignedCertificate + marked oid cert = case certExtensions (getCertificate cert) of + Extensions (Just exts) -> any ((== oid) . extRawOID) exts + Extensions Nothing -> False + receiptSigningLeaf = [1, 2, 840, 113635, 100, 6, 11, 1] + appleIntermediate = [1, 2, 840, 113635, 100, 6, 2, 1] + +leafKey :: SignedCertificate -> Either StoreRefusal ECDSA.PublicKey +leafKey leaf = case certPubKey (getCertificate leaf) of + PubKeyEC (PubKeyEC_Named ECC.SEC_p256r1 pt) | Just p <- unserializePoint p256 pt -> Right $ ECDSA.PublicKey p256 p + _ -> failed "the leaf Apple issued holds no P-256 key" + where + p256 = ECC.getCurveByName ECC.SEC_p256r1 + +-- | JWS carries ES256 as r and s, 32 bytes each, not the DER a certificate uses. +jwsSignature :: Text -> Either StoreRefusal ECDSA.Signature +jwsSignature s = case B64U.decodeUnpadded (encodeUtf8 s) of + Right bs | B.length bs == 64 -> let (r, s') = B.splitAt 32 bs in Right $ ECDSA.Signature (os2ip r) (os2ip s') + _ -> refused "the signature is not 64 base64url bytes" + +data AppleTransaction = AppleTransaction + { transactionId :: Text, + productId :: Text, + bundleId :: Text, + environment :: Text, + quantity :: Int, + revoked :: Bool, + price :: Maybe Integer, + currency :: Maybe Text + } + +appleTransactionP :: J.Object -> JT.Parser AppleTransaction +appleTransactionP o = do + transactionId <- o J..: "transactionId" + productId <- o J..: "productId" + bundleId <- o J..: "bundleId" + environment <- o J..: "environment" + quantity <- o J..: "quantity" + revoked <- isJust <$> (o J..:? "revocationDate" :: JT.Parser (Maybe J.Value)) + price <- o J..:? "price" + currency <- o J..:? "currency" + pure AppleTransaction {transactionId, productId, bundleId, environment, quantity, revoked, price, currency} + +transaction :: Text -> AppleTransaction -> Either StoreRefusal VerifiedStoreTransaction +transaction ourBundleId AppleTransaction {transactionId, productId, bundleId, environment, quantity, revoked, price, currency} + -- the configured bundle id may be the wrong one, and refusing for good would destroy every genuine purchase + | bundleId /= ourBundleId = failed "another app's bundle id, or ours misconfigured" + | revoked = refused "refunded or revoked" + | otherwise = do + testPurchase <- case environment of + "Production" -> Right False + "Sandbox" -> Right True + _ -> failed "an environment this verifier does not know" + Right + VerifiedStoreTransaction + { transactionRef = transactionId, + productId, + quantity, + testPurchase, + paid = paidOf price currency, + alreadyAcknowledged = False + } + +-- | Apple's price is in milliunits of the currency, whatever its minor unit. +paidOf :: Maybe Integer -> Maybe Text -> Maybe (CurrencyAmount, Text) +paidOf price_ currency_ = do + milliunits <- price_ + code <- currency_ + digits <- minorUnitDigits code + let (minor, rest) = (milliunits * 10 ^ digits) `divMod` 1000 + guard $ rest == 0 && minor >= 0 && minor <= toInteger (maxBound :: Word32) + pure (CurrencyAmount $ fromInteger minor, code) + +-- | ISO 4217 as of its 2026-09-17 list; four-digit and non-currency codes are not recorded. +minorUnitDigits :: Text -> Maybe Integer +minorUnitDigits c + | c `elem` ["BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF"] = Just 0 + | c `elem` ["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"] = Just 3 + | c `elem` ["CLF", "UYW", "UYI", "XAG", "XAU", "XBA", "XBB", "XBC", "XBD", "XDR", "XPD", "XPT", "XSU", "XTS", "XUA", "XXX"] = Nothing + | T.length c == 3 && T.all (\x -> x >= 'A' && x <= 'Z') c = Just 2 + | otherwise = Nothing + +jsonObject :: Text -> Maybe J.Object +jsonObject part = J.decodeStrict' =<< eitherToMaybe (B64U.decodeUnpadded $ encodeUtf8 part) + +refused :: Text -> Either StoreRefusal a +refused = Left . SRInvalid + +failed :: Text -> Either StoreRefusal a +failed = Left . SRVerifierFailed . ("apple verifier: " <>) + +signedBy :: SignedCertificate -> SignedCertificate -> Bool +signedBy issuer cert = verifySignedSignature cert (certPubKey $ getCertificate issuer) == SignaturePass + +readAppleRoot :: FilePath -> IO (Either String SignedCertificate) +readAppleRoot path = + try (B.readFile path) >>= \case + Left (_ :: IOException) -> pure $ Left $ path <> ": could not be read" + Right der -> pure $ either (const $ Left $ path <> ": is not a DER certificate") checkRoot $ decodeSignedCertificate der + where + checkRoot root + | signedBy root root = Right root + | otherwise = Left $ path <> ": is not a self-signed root certificate" diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs new file mode 100644 index 0000000000..98cb1fc050 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs @@ -0,0 +1,232 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} + +module BadgeService.StoreReceipts.Google (playStoreVerifier) where + +import BadgeService.Config (PlayStoreConfig (..)) +import BadgeService.StoreReceipts (StoreRefusal (..), VerifiedStoreTransaction (..)) +import Control.Concurrent.STM +import Control.Exception (IOException, try) +import Crypto.Hash.Algorithms (SHA256 (..)) +import qualified Crypto.PubKey.RSA as RSA +import qualified Crypto.PubKey.RSA.PKCS15 as RSA +import qualified Data.Aeson as J +import Data.Bifunctor (bimap) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Lazy as LB +import Data.Functor (($>)) +import Data.Int (Int64) +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime) +import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds) +import Data.X509 (PrivKey (..)) +import Data.X509.Memory (readKeyFileFromMemory) +import Network.HTTP.Client + ( HttpException (..), + Manager, + Request (..), + RequestBody (..), + Response (..), + brReadSome, + parseRequest, + withResponse, + ) +import Network.HTTP.Client.TLS (newTlsManager) +import Network.HTTP.Types (Status (..), hAuthorization, hContentType, methodPost, renderSimpleQuery) +import Simplex.Chat.PaymentService (googlePurchaseRef) +import Simplex.Messaging.Util (tshow) + +data ServiceAccount = ServiceAccount {clientEmail :: Text, privateKey :: RSA.PrivateKey} + +data PlayEnv = PlayEnv + { config :: PlayStoreConfig, + account :: ServiceAccount, + manager :: Manager, + accessToken :: TVar (Maybe (ByteString, UTCTime)) + } + +playStoreVerifier :: PlayStoreConfig -> IO (Either String (Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction), Text -> Text -> IO (Either StoreRefusal ()))) +playStoreVerifier config@PlayStoreConfig {gServiceAccountFile} = + readServiceAccount gServiceAccountFile >>= \case + Left e -> pure $ Left e + Right account -> do + manager <- newTlsManager + accessToken <- newTVarIO Nothing + let env = PlayEnv {config, account, manager, accessToken} + pure $ Right (verifyPurchase env, acknowledgePurchase env) + +readServiceAccount :: FilePath -> IO (Either String ServiceAccount) +readServiceAccount path = + try (B.readFile path) >>= \case + Left (_ :: IOException) -> pure $ Left $ path <> ": could not be read" + Right s -> pure $ case J.decodeStrict' s of + Just (ServiceAccountFile clientEmail pem) -> case readKeyFileFromMemory (encodeUtf8 pem) of + [PrivKeyRSA privateKey] -> Right ServiceAccount {clientEmail, privateKey} + _ -> Left $ path <> ": private_key is not one RSA key" + Nothing -> Left $ path <> ": is not a service account key with client_email and private_key" + +data ServiceAccountFile = ServiceAccountFile Text Text + +instance J.FromJSON ServiceAccountFile where + parseJSON = J.withObject "service account key" $ \o -> ServiceAccountFile <$> o J..: "client_email" <*> o J..: "private_key" + +verifyPurchase :: PlayEnv -> Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +verifyPurchase env productId token = (>>= purchaseVerdict productId token) <$> readPurchase env productId token + +-- | Acknowledged, never consumed: consuming drops the purchase from the app's purchase query, +-- which is how the app finds a purchase it has not yet settled. +acknowledgePurchase :: PlayEnv -> Text -> Text -> IO (Either StoreRefusal ()) +acknowledgePurchase env productId token = + bimap acknowledging (const ()) <$> playCall env (\req -> req {method = methodPost}) (purchaseUrl env productId token <> ":acknowledge") + where + acknowledging = \case + SRUnreachable reason -> SRUnreachable $ "acknowledging: " <> reason + SRVerifierFailed reason -> SRVerifierFailed $ "acknowledging: " <> reason + refusal -> refusal + +readPurchase :: PlayEnv -> Text -> Text -> IO (Either StoreRefusal ProductPurchase) +readPurchase env productId token = (>>= decoded) <$> playCall env id (purchaseUrl env productId token) + where + decoded = either (const $ Left $ SRVerifierFailed "could not read Play's purchase record") Right . J.eitherDecode' + +purchaseUrl :: PlayEnv -> Text -> Text -> Text +purchaseUrl PlayEnv {config = PlayStoreConfig {gApiHost, gPackageName}} productId token = + T.intercalate "/" [gApiHost, "androidpublisher/v3/applications", gPackageName, "purchases/products", productId, "tokens", token] + +playCall :: PlayEnv -> (Request -> Request) -> Text -> IO (Either StoreRefusal LB.ByteString) +playCall env@PlayEnv {accessToken} prepare url = + bearerToken env >>= \case + Left refusal -> pure $ Left refusal + Right bearer -> + playRequest env (\req -> (prepare req) {requestHeaders = [(hAuthorization, "Bearer " <> bearer)]}) url >>= \case + Left refusal -> pure $ Left refusal + Right (code, body) | code >= 200 && code < 300 -> pure $ Right body + Right (401, _) -> atomically (writeTVar accessToken Nothing) $> Left (SRVerifierFailed "Play refused this service's access token") + Right (403, _) -> pure $ Left $ SRVerifierFailed "the service account lacks the Play Console permission for this call" + -- a 404 among them, which Play may answer for a token it issued but has not yet recorded + Right (code, _) -> pure $ Left $ SRUnreachable $ "Play answered HTTP " <> tshow code + +purchaseVerdict :: Text -> Text -> ProductPurchase -> Either StoreRefusal VerifiedStoreTransaction +purchaseVerdict productId token ProductPurchase {purchaseState, purchaseType, purchaseQuantity, purchaseProductId, alreadyAcknowledged} + | maybe False (/= productId) purchaseProductId = Left $ SRVerifierFailed "Play answered about another product" + | otherwise = case purchaseState of + 0 -> purchased <$> testPurchaseOf purchaseType + -- a canceled purchase stays canceled, and buying again issues a new token + 1 -> Left $ SRInvalid "canceled" + 2 -> Left SRPending + s -> Left $ SRVerifierFailed $ "an unknown purchaseState " <> tshow s + where + purchased testPurchase = + VerifiedStoreTransaction + { transactionRef = googlePurchaseRef token, + productId, + quantity = fromMaybe 1 purchaseQuantity, + testPurchase, + paid = Nothing, + alreadyAcknowledged + } + testPurchaseOf = \case + Nothing -> Right False + Just 0 -> Right True + -- a promo code, which only this app's developer can issue + Just 1 -> Right False + Just t -> Left $ SRVerifierFailed $ "a purchaseType this service does not credit: " <> tshow t + +data ProductPurchase = ProductPurchase + { purchaseState :: Int, + purchaseType :: Maybe Int, + purchaseQuantity :: Maybe Int, + purchaseProductId :: Maybe Text, + alreadyAcknowledged :: Bool + } + +instance J.FromJSON ProductPurchase where + parseJSON = J.withObject "ProductPurchase" $ \o -> do + purchaseState <- o J..: "purchaseState" + purchaseType <- o J..:? "purchaseType" + purchaseQuantity <- o J..:? "quantity" + purchaseProductId <- o J..:? "productId" + acknowledgementState <- o J..:? "acknowledgementState" + consumptionState <- o J..:? "consumptionState" + let alreadyAcknowledged = acknowledgementState == Just (1 :: Int) || consumptionState == Just (1 :: Int) + pure ProductPurchase {purchaseState, purchaseType, purchaseQuantity, purchaseProductId, alreadyAcknowledged} + +bearerToken :: PlayEnv -> IO (Either StoreRefusal ByteString) +bearerToken env@PlayEnv {accessToken} = do + now <- getCurrentTime + readTVarIO accessToken >>= \case + Just (bearer, validUntil) | now < validUntil -> pure $ Right bearer + _ -> requestAccessToken env now + +requestAccessToken :: PlayEnv -> UTCTime -> IO (Either StoreRefusal ByteString) +requestAccessToken env@PlayEnv {config = PlayStoreConfig {gTokenUrl}, account, accessToken} now = + signedAssertion account gTokenUrl now >>= \case + Nothing -> pure $ Left $ SRVerifierFailed "could not sign the service account assertion" + Just assertion -> + playRequest env (tokenForm assertion) gTokenUrl >>= \case + Left refusal -> pure $ Left refusal + Right (200, body) -> case J.eitherDecode' body of + Right (GrantedToken bearer expiresIn) -> do + atomically $ writeTVar accessToken $ Just (bearer, addUTCTime (fromIntegral expiresIn - refreshMargin) now) + pure $ Right bearer + Left _ -> pure $ Left $ SRVerifierFailed "could not read Google's access token" + Right (code, _) + | code >= 500 || code == 429 -> pure $ Left $ SRUnreachable $ "Google's token endpoint answered HTTP " <> tshow code + | otherwise -> pure $ Left $ SRVerifierFailed $ "Google refused the service account assertion: HTTP " <> tshow code + where + tokenForm assertion req = + req + { method = methodPost, + requestHeaders = [(hContentType, "application/x-www-form-urlencoded")], + requestBody = RequestBodyBS $ renderSimpleQuery False [("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), ("assertion", assertion)] + } + +refreshMargin :: NominalDiffTime +refreshMargin = 300 + +data GrantedToken = GrantedToken ByteString Int + +instance J.FromJSON GrantedToken where + parseJSON = J.withObject "access token" $ \o -> GrantedToken . encodeUtf8 <$> o J..: "access_token" <*> o J..: "expires_in" + +signedAssertion :: ServiceAccount -> Text -> UTCTime -> IO (Maybe ByteString) +signedAssertion ServiceAccount {clientEmail, privateKey} audience now = + either (const Nothing) (\sig -> Just $ signingInput <> "." <> B64U.encodeUnpadded sig) <$> RSA.signSafer (Just SHA256) privateKey signingInput + where + issuedAt = floor (utcTimeToPOSIXSeconds now) :: Int64 + signingInput = segment (J.object ["alg" J..= ("RS256" :: Text), "typ" J..= ("JWT" :: Text)]) <> "." <> segment claims + claims = + J.object + [ "iss" J..= clientEmail, + "scope" J..= ("https://www.googleapis.com/auth/androidpublisher" :: Text), + "aud" J..= audience, + "iat" J..= issuedAt, + "exp" J..= (issuedAt + 3600) + ] + segment = B64U.encodeUnpadded . LB.toStrict . J.encode + +playRequest :: PlayEnv -> (Request -> Request) -> Text -> IO (Either StoreRefusal (Int, LB.ByteString)) +playRequest PlayEnv {manager} prepare url = + try (parseRequest (T.unpack url) >>= \req -> withResponse (prepare req) manager readBody) >>= \case + -- http-client's exceptions show the request, whose URL can hold the purchase token, so only the kind is kept + Left (e :: HttpException) -> pure $ Left $ SRUnreachable $ "request to Google failed: " <> failureKind e + Right (code, body) + | LB.length body > maxPlayBytes -> pure $ Left $ SRVerifierFailed $ "Google answered HTTP " <> tshow code <> " with over " <> tshow maxPlayBytes <> " bytes" + | otherwise -> pure $ Right (code, body) + where + readBody resp = (statusCode (responseStatus resp),) <$> brReadSome (responseBody resp) (fromIntegral maxPlayBytes + 1) + failureKind = \case + HttpExceptionRequest _ content -> T.pack $ takeWhile (/= ' ') $ show content + InvalidUrlException _ _ -> "InvalidUrlException" + +maxPlayBytes :: Int64 +maxPlayBytes = 1024 * 1024 diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs new file mode 100644 index 0000000000..f7b1a14c9b --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs @@ -0,0 +1,41 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.StoreReceipts.Mock (mockStoreVerifier) where + +import BadgeService.StoreReceipts +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as JM +import qualified Data.ByteString.Base64.URL as B64U +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat.PaymentService (appleTransactionId, googlePurchaseRef) +import Simplex.Messaging.Util (eitherToMaybe) + +-- | Vouches for any well-formed receipt without verifying anything. Each transaction is named by +-- the same function its claim is named by, so the two cannot differ. +mockStoreVerifier :: StoreVerifier +mockStoreVerifier = noStoreVerifier {verifyApple = Just mockApple, verifyGoogle = Just mockGoogle} + +mockApple :: Text -> Either StoreRefusal VerifiedStoreTransaction +mockApple signed = case T.splitOn "." signed of + [_, payload, _] -> do + o <- case J.decodeStrict' =<< eitherToMaybe (B64U.decodeUnpadded $ encodeUtf8 payload) of + Just (J.Object obj) -> Right obj + _ -> Left $ SRInvalid "payload is not base64url JSON" + productId <- case JM.lookup "productId" o of + Just (J.String p) -> Right p + _ -> Left $ SRInvalid "no productId" + transactionRef <- maybe (Left $ SRInvalid "no transactionId") Right $ appleTransactionId signed + Right $ vouched transactionRef productId + _ -> Left $ SRInvalid "not three dot-separated parts" + +mockGoogle :: Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction) +mockGoogle productId token = pure $ Right $ vouched (googlePurchaseRef token) productId + +vouched :: Text -> Text -> VerifiedStoreTransaction +vouched transactionRef productId = + -- not a test purchase, though nothing was paid: a test purchase is refused as test_purchase, which is + -- terminal and drops the client's keys, so every dev purchase would fail for good + VerifiedStoreTransaction {transactionRef, productId, quantity = 1, testPurchase = False, paid = Nothing, alreadyAcknowledged = False} diff --git a/apps/simplex-badge-service/test-fixtures/apple/AppleRootCA-G3.cer b/apps/simplex-badge-service/test-fixtures/apple/AppleRootCA-G3.cer new file mode 100644 index 0000000000..228bfa39cb Binary files /dev/null and b/apps/simplex-badge-service/test-fixtures/apple/AppleRootCA-G3.cer differ diff --git a/apps/simplex-badge-service/test-fixtures/apple/transaction-legend.json b/apps/simplex-badge-service/test-fixtures/apple/transaction-legend.json new file mode 100644 index 0000000000..72ee85416c --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/apple/transaction-legend.json @@ -0,0 +1,19 @@ +{ + "transactionId": "2000000812345672", + "originalTransactionId": "2000000812345672", + "bundleId": "chat.simplex.app", + "productId": "BADGE_LEGEND_01", + "purchaseDate": 1790000000000, + "originalPurchaseDate": 1790000000000, + "quantity": 1, + "type": "Consumable", + "appAccountToken": "0b6a7e1d-5c3f-4a8e-b2d4-9e1f6c7a8b20", + "inAppOwnershipType": "PURCHASED", + "signedDate": 1790000001000, + "environment": "Production", + "transactionReason": "PURCHASE", + "storefront": "USA", + "storefrontId": "143441", + "price": 70000, + "currency": "USD" +} diff --git a/apps/simplex-badge-service/test-fixtures/apple/transaction-sandbox.json b/apps/simplex-badge-service/test-fixtures/apple/transaction-sandbox.json new file mode 100644 index 0000000000..e61c63d2a7 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/apple/transaction-sandbox.json @@ -0,0 +1,19 @@ +{ + "transactionId": "2000000812345673", + "originalTransactionId": "2000000812345673", + "bundleId": "chat.simplex.app", + "productId": "BADGE_LEGEND_01", + "purchaseDate": 1790000000000, + "originalPurchaseDate": 1790000000000, + "quantity": 1, + "type": "Consumable", + "appAccountToken": "4d2c9a61-8e3b-4f70-a1c5-6b0e9d2f3a47", + "inAppOwnershipType": "PURCHASED", + "signedDate": 1790000001000, + "environment": "Sandbox", + "transactionReason": "PURCHASE", + "storefront": "USA", + "storefrontId": "143441", + "price": 70000, + "currency": "USD" +} diff --git a/apps/simplex-badge-service/test-fixtures/apple/transaction-supporter.json b/apps/simplex-badge-service/test-fixtures/apple/transaction-supporter.json new file mode 100644 index 0000000000..267184c036 --- /dev/null +++ b/apps/simplex-badge-service/test-fixtures/apple/transaction-supporter.json @@ -0,0 +1,19 @@ +{ + "transactionId": "2000000812345671", + "originalTransactionId": "2000000812345671", + "bundleId": "chat.simplex.app", + "productId": "BADGE_SUPPORTER_01", + "purchaseDate": 1790000000000, + "originalPurchaseDate": 1790000000000, + "quantity": 1, + "type": "Consumable", + "appAccountToken": "7f7c0f43-4b7b-4c2e-9d57-1b7a2b1f3c11", + "inAppOwnershipType": "PURCHASED", + "signedDate": 1790000001000, + "environment": "Production", + "transactionReason": "PURCHASE", + "storefront": "USA", + "storefrontId": "143441", + "price": 7000, + "currency": "USD" +} diff --git a/apps/simplex-badge-service/web/README.md b/apps/simplex-badge-service/web/README.md index 0004b44944..ae2f5b37e5 100644 --- a/apps/simplex-badge-service/web/README.md +++ b/apps/simplex-badge-service/web/README.md @@ -56,10 +56,10 @@ is plain TypeScript, tested directly in Node. | `format.ts` | Money, countdowns and elapsed time as the words a screen prints | | `store.ts` | The three `localStorage` keys — session, orders and the chosen theme | | `order.ts` | What an order's state means: what to keep from a response, which screen it selects, and its history row | -| `routing.ts` | Reading `?order=` and the store into what to render | +| `routing.ts` | Reading `?order=`, the step hash with its `?app=` flag, and the store into what to render; the `simplexchat:` link that hands a code to the app | | `api.ts` | The three routes — create, cancel and read — and the long-poll wait loop, with `fetch`, sleep and the clock injected | | `flow.ts` | Payment flow control logic — pure, no DOM, no globals | -| `stripe.ts` | Loading Stripe.js, mounting the Payment Element, confirming, and the no-key stand-in | +| `stripe.ts` | Loading Stripe.js, mounting the Payment Element, confirming, and refusing the card path with no key | | `icons.ts` | The badge art, the payment marks and the hamburger, built with `createElementNS`. The brand mark is NOT here: it is a served file | | `qr.ts` | A QR encoder written for this page, with no dependency, no network and no raster | | `screens.ts` | Every screen of the spec, and the header and its menu, built node by node — markup is never assigned from a string | @@ -183,6 +183,29 @@ which is what the page's own wait loop relies on — no polling on a timer. Card invoices (`"method":"card"`) get a `clientSecret` in the response instead of an address; BTC and XMR get `address` and `cryptoAmount`. +### Opening the page as the app does + +The SimpleX app opens the page at a step hash carrying a flag, which decides +only how the code is handed over at the end. Both endings can be driven here: + +``` +http://localhost:8099/#/tier?app=true # Return to SimpleX, with Show code under it +http://localhost:8099/#/tier?app=desktop # the code screen, saying the Redeem code screen is already open +``` + +With no flag the page ends as it always has. + +**Open each in a fresh private window.** The flag is read once, at load, and +kept in the session; nothing re-reads it when the hash changes, so editing the +address bar of a page that is already open does nothing. A session left over +from an earlier purchase in the same browser has already spent its flag at +checkout, and the ending falls back to the plain code screen. + +Pay with Monero or Bitcoin and settle it with `/control/settle` above — the +card path needs a Stripe key. A code the browser could not store also falls +back to the plain screen, so a browser that blocks `localStorage` never shows +the `app=true` ending at all. + ### The Stripe key The publishable key lives in a `` element @@ -191,12 +214,10 @@ in `public/index.html`, committed empty. `mock/server.py` substitutes set to anything but a `pk_`-prefixed key (a secret or restricted key would otherwise be baked into a page anyone can read). -With no key set, the card path renders a labelled development stand-in -instead of a Stripe Payment Element: its button does what a successful -confirm does, and settling it calls the mock's `/control/settle` directly. -**This stand-in cannot appear when a key is set** — the code path that -builds it is unreachable once Stripe.js has actually loaded. To see the real -Payment Element, set a test key: +With no key set there is no card path at all: `cardPlan` answers +`unconfigured`, `renderCardForm` returns before it builds anything, and +choosing Card reaches "The card form did not load". Drive a purchase with +Monero or Bitcoin instead, or set a test key: ``` STRIPE_PUBLISHABLE_KEY=pk_test_... python3 mock/server.py --port 8099 diff --git a/apps/simplex-badge-service/web/mock/server.py b/apps/simplex-badge-service/web/mock/server.py index af7612a084..009aea31d4 100644 --- a/apps/simplex-badge-service/web/mock/server.py +++ b/apps/simplex-badge-service/web/mock/server.py @@ -9,9 +9,9 @@ Environment: STRIPE_PUBLISHABLE_KEY substituted into the served index.html. Public by design, but still not committed: unset, the page has NO card form and - renders the development stand-in instead, whose - button does what a successful confirm does and whose - settling is POST /control/settle/ below. + choosing Card reaches "The card form did not load", + so drive a purchase with btc or xmr and settle it + with POST /control/settle/ below. Set it to a `pk_test_...` key to drive the real Stripe path; a secret key here is refused at start. """ @@ -111,8 +111,8 @@ def public_view(inv): def with_publishable_key(html): """The publishable key is compiled into the page. Here it comes from the environment, so nothing that could be a real key is ever written - back into public/index.html. Unset leaves the committed empty value, which - is what selects the development stand-in.""" + back into public/index.html. Unset leaves the committed empty value, with + which the page offers no card form at all.""" if not STRIPE_PUBLISHABLE_KEY: return html text, found = KEY_META.subn(lambda m: m.group(1) + STRIPE_PUBLISHABLE_KEY + m.group(2), html.decode()) @@ -314,7 +314,7 @@ def main(): print(f"mock badge service on http://localhost:{port}", flush=True) print("stripe: " + (f"publishable key {STRIPE_PUBLISHABLE_KEY[:11]}… — the real card form" if STRIPE_PUBLISHABLE_KEY - else "no STRIPE_PUBLISHABLE_KEY — the card path renders the development stand-in"), + else "no STRIPE_PUBLISHABLE_KEY — no card form; pay with btc or xmr"), flush=True) server.serve_forever() diff --git a/apps/simplex-badge-service/web/public/index.html b/apps/simplex-badge-service/web/public/index.html index dcf5e7d4b2..9885481483 100644 --- a/apps/simplex-badge-service/web/public/index.html +++ b/apps/simplex-badge-service/web/public/index.html @@ -20,17 +20,17 @@ Support SimpleX - - + + - + - + - + diff --git a/apps/simplex-badge-service/web/public/styles.css b/apps/simplex-badge-service/web/public/styles.css index 2fee434fcd..760cc259e7 100644 --- a/apps/simplex-badge-service/web/public/styles.css +++ b/apps/simplex-badge-service/web/public/styles.css @@ -477,6 +477,10 @@ footer a:hover { text-decoration: underline; } .panel > .primary { margin-top: auto; } .panel > .notes { margin-top: auto; } .notes + .primary { margin-top: 20px; } +/* The ending's button keeps the foot position every screen gives it, but the text above it + keeps a floor: once Show code fills the screen there is no free space for the auto margin, + and the button would otherwise land against the line it follows. */ +.panel.ending > .lede { margin-bottom: 24px; } .notes .muted { margin: 4px auto 0; max-width: 480px; } /* The checkout's note slot holds one line's height whether or not the line is shown, so choosing a method moves nothing above or below it. */ diff --git a/apps/simplex-badge-service/web/public/sw.js b/apps/simplex-badge-service/web/public/sw.js index 8faa9d0c1f..447530421a 100644 --- a/apps/simplex-badge-service/web/public/sw.js +++ b/apps/simplex-badge-service/web/public/sw.js @@ -2,7 +2,7 @@ // runs this exact file in a Node `vm` with a fake Cache API and reads `self.sw`. /** Rewritten by `build.js` from the bytes of the compiled modules, the stylesheet and the images. */ -const BUILD = "9539a668abb6a8c8"; +const BUILD = "c1e049e40dfe7be6"; const ASSETS = `/assets/${BUILD}/`; /** One cache per build, so eviction is "everything that is not this one". */ const CACHE = `sb-${BUILD}`; diff --git a/apps/simplex-badge-service/web/src/domain.ts b/apps/simplex-badge-service/web/src/domain.ts index 4bae3d13b9..3baba3656f 100644 --- a/apps/simplex-badge-service/web/src/domain.ts +++ b/apps/simplex-badge-service/web/src/domain.ts @@ -5,18 +5,22 @@ export const THEMES = ["light", "dark", "system"] as const; const ORDER_STATUSES = ["open", "paid", "expired"] as const; const METHODS = ["card", "btc", "xmr"] as const; const CHAINS = ["btc", "xmr"] as const; +const APPS = ["mobile", "desktop"] as const; export type Step = (typeof STEPS)[number]; export type Theme = (typeof THEMES)[number]; export type OrderStatus = (typeof ORDER_STATUSES)[number]; export type Method = (typeof METHODS)[number]; export type Chain = (typeof CHAINS)[number]; +/** The SimpleX app that opened the page, which decides how the code is handed over at the end. */ +export type App = (typeof APPS)[number]; export const readStep = oneOf(STEPS); export const readTheme = oneOf(THEMES); export const readStatus = oneOf(ORDER_STATUSES); export const readMethod = oneOf(METHODS); export const readChain = oneOf(CHAINS); +export const readApp = oneOf(APPS); /** The optional fields are written `| undefined` so a patch can set one back to undefined to clear it. */ export interface SessionRecord { @@ -24,6 +28,7 @@ export interface SessionRecord { priceId?: string | undefined; offerId?: string | undefined; method?: Method | undefined; + app?: App | undefined; } export interface OrderIdentity { @@ -39,6 +44,7 @@ export interface OrderLocalState { submitted: boolean | undefined; canceled: boolean | undefined; method: Method | undefined; + app: App | undefined; } /** Every field is a required key even when undefined, so a withdrawn field cannot survive in the stored record. */ diff --git a/apps/simplex-badge-service/web/src/flow.ts b/apps/simplex-badge-service/web/src/flow.ts index 13da0949d0..af675eac04 100644 --- a/apps/simplex-badge-service/web/src/flow.ts +++ b/apps/simplex-badge-service/web/src/flow.ts @@ -9,7 +9,7 @@ import { applyView, closedInvoice, offlineInvoice, recordFromView, serverState, viewFor, withoutCode, type PaymentView, } from "./order.js"; -import type { Method, OrderRecord, OrderStatus } from "./domain.js"; +import type { App, Method, OrderRecord, OrderStatus } from "./domain.js"; import type { Store } from "./store.js"; export const GIVE_UP_MS = 15 * 60 * 1000; @@ -285,7 +285,7 @@ export class Flow { constructor(private readonly d: FlowDeps) {} - async checkout(sel: Selection): Promise { + async checkout(sel: Selection, app?: App): Promise { for (let attempt = 0; attempt < CODE_ATTEMPTS; attempt++) { const wipes = this.d.store.wipeCount; const code = this.d.newCode(); @@ -316,7 +316,7 @@ export class Flow { months: created.months, createdAt: new Date(this.d.now()).toISOString(), }, - { code: display(code), submitted: undefined, canceled: undefined, method: sel.method }, + { code: display(code), submitted: undefined, canceled: undefined, method: sel.method, app }, serverState({ ...created, status: "open" }, undefined), ); // The buyer can empty this browser while the invoice is bought, so the order is saved only diff --git a/apps/simplex-badge-service/web/src/main.ts b/apps/simplex-badge-service/web/src/main.ts index 310fa93220..3bb4917ddc 100644 --- a/apps/simplex-badge-service/web/src/main.ts +++ b/apps/simplex-badge-service/web/src/main.ts @@ -5,12 +5,12 @@ import { Flow, type CheckoutOutcome, type Selection } from "./flow.js"; import { applyView, historyRows, selectionFromOrder, type PaymentView, withoutDestination } from "./order.js"; import { money, moneyCompact } from "./format.js"; import * as api from "./api.js"; -import { resolveLoad } from "./routing.js"; +import { appCodeLink, readHash, resolveLoad } from "./routing.js"; import * as screens from "./screens.js"; import { appearanceFor, cardPlan, loadStripeJs, mountCard, publishableKey, type CardFailure, type ConfirmOutcome } from "./stripe.js"; import { Store, type StorageLike } from "./store.js"; import { STEPS } from "./domain.js"; -import type { Method, OrderRecord, SessionRecord, Step, Theme } from "./domain.js"; +import type { App, Method, OrderRecord, SessionRecord, Step, Theme } from "./domain.js"; import { EMBED_READY, HEIGHT_MESSAGE, NAV_MESSAGE, THEME_MESSAGE, bgFromMessage, isNewPurchaseMessage, returnUrlFromMessage, routeFromMessage, themeFromMessage, trustedHost } from "./embed.js"; const app = document.getElementById("app"); @@ -268,7 +268,8 @@ const panels: HTMLElement[] = []; let index = 0; function landingIndex(): number { - const fromHash = STEPS.findIndex((s) => HASHES[s] === location.hash); + const { route } = readHash(location.hash); + const fromHash = STEPS.findIndex((s) => HASHES[s] === route); return fromHash >= 0 ? fromHash + 1 : 0; } @@ -486,7 +487,7 @@ async function pay(): Promise { const since = flow.epoch; let outcome: CheckoutOutcome; try { - outcome = await flow.checkout(sel); + outcome = await flow.checkout(sel, session.app); } finally { checkoutInFlight = false; } @@ -507,7 +508,7 @@ async function pay(): Promise { } case "catalogChanged": root.replaceChildren(screens.catalogChanged(() => { - newInvoice(); + newInvoice(session.app); })); return; case "rateLimited": { @@ -606,7 +607,7 @@ async function cancelInvoice(orderId: string): Promise { return; } store.markCanceled(orderId); - newInvoice(); + newInvoice(store.order(orderId)?.app); } // The Cancel button's disabled attribute does not survive a repaint, so this flag allows one cancel at a time. @@ -631,8 +632,10 @@ function resetToLanding(nav: "push" | "replace"): void { announceLocation(); } -function newInvoice(): void { +// A purchase started over keeps the app that opened the page, or its order would end by showing the code. +function newInvoice(openedBy: App | undefined): void { store.clearSession(); + if (openedBy !== undefined) store.saveSession({ app: openedBy }); resetToLanding("replace"); } @@ -664,10 +667,10 @@ function paint(view: PaymentView): void { syncChrome(); switch (view.screen) { case "unknownOrder": - root.replaceChildren(screens.unknownOrder(newInvoice)); + root.replaceChildren(screens.unknownOrder(() => { newInvoice(store.session().app); })); return; case "codeIssued": - root.replaceChildren(screens.codeIssued({ code: view.code, savedLocally: view.savedLocally })); + root.replaceChildren(codeScreen(view)); return; case "paidNoCode": root.replaceChildren(screens.paidNoCode({ order: view.order, settledAt: view.invoice?.settledAt })); @@ -695,7 +698,7 @@ function paint(view: PaymentView): void { root.replaceChildren(screens.windowClosed({ order: view.order, invoice: view.invoice, offline: isOffline(), canceled: store.order(view.order.orderId)?.canceled === true, - onNewInvoice: newInvoice, + onNewInvoice: () => { newInvoice(view.order.app); }, })); return; case "cardForm": @@ -704,12 +707,38 @@ function paint(view: PaymentView): void { case "detailsUnavailable": root.replaceChildren(screens.detailsUnavailable({ order: view.order, - onCheckAgain: () => checkAgain(view.order.orderId), onNewInvoice: newInvoice, + onCheckAgain: () => checkAgain(view.order.orderId), onNewInvoice: () => { newInvoice(view.order.app); }, })); return; } } +type CodeView = Extract; + +const codeRevealed = new Set(); +const appLaunched = new Set(); + +function codeScreen(view: CodeView): HTMLElement { + const { orderId } = view.order; + // An unsaved code exists only on this screen, so it is never held back behind a link that may fail. + if (view.order.app !== "mobile" || !view.savedLocally) { + return screens.codeIssued({ code: view.code, savedLocally: view.savedLocally, app: view.order.app }); + } + const link = appCodeLink(view.code); + const ending = screens.returnToApp({ + onReturn: () => { location.href = link; }, + code: codeRevealed.has(orderId) + ? { kind: "shown", code: view.code, savedLocally: view.savedLocally } + : { kind: "hidden", onShow: () => { codeRevealed.add(orderId); paint(view); } }, + }); + // A connectivity event repaints this screen, and each repaint must not fire the scheme again. + if (!appLaunched.has(orderId)) { + appLaunched.add(orderId); + ending.append(screens.appLauncher(link)); + } + return ending; +} + let cardElementDestroy: (() => void) | null = null; let cardConfirmPending = false; @@ -783,7 +812,7 @@ function cardFailureScreen(view: CardView, reason: CardFailure): HTMLElement { return screens.cardUnavailable({ order: view.order, reason, onRetry: () => { renderCardForm(view); }, - onNewInvoice: newInvoice, + onNewInvoice: () => { newInvoice(view.order.app); }, }); } @@ -925,6 +954,10 @@ function registerServiceWorker(): void { // The shell prerenders the welcome page, so the document is that page until the first render, and a load resolving to a codes or order screen never mounts the wizard for measureWelcome to read. if (embedded && welcomeHeight <= 0) welcomeHeight = Math.ceil(document.documentElement.getBoundingClientRect().height); +// Read before the first sync rewrites the hash, which drops the flag from the URL. +const entryApp = readHash(location.hash).app; +if (entryApp !== undefined) store.saveSession({ app: entryApp }); + syncFromLocation(true); document.documentElement.classList.remove("sb-booting"); registerServiceWorker(); diff --git a/apps/simplex-badge-service/web/src/order.ts b/apps/simplex-badge-service/web/src/order.ts index 0370c55135..6bb40aa1ba 100644 --- a/apps/simplex-badge-service/web/src/order.ts +++ b/apps/simplex-badge-service/web/src/order.ts @@ -138,6 +138,7 @@ function localState(base: OrderRecord, memory: OrderRecord | undefined, method: submitted: memory?.submitted === true || base.submitted === true ? true : undefined, canceled: memory?.canceled === true || base.canceled === true ? true : undefined, method: method ?? base.method, + app: memory?.app ?? base.app, }; } diff --git a/apps/simplex-badge-service/web/src/routing.ts b/apps/simplex-badge-service/web/src/routing.ts index f5b50cbcb2..8557e3e9c6 100644 --- a/apps/simplex-badge-service/web/src/routing.ts +++ b/apps/simplex-badge-service/web/src/routing.ts @@ -1,4 +1,4 @@ -import type { Method, OrderRecord } from "./domain.js"; +import type { App, Method, OrderRecord } from "./domain.js"; export type Screen = "awaitingPayment" | "awaitingConfirmation" | "cardForm"; @@ -6,6 +6,24 @@ export type Load = { kind: "order"; orderId: string } | { kind: "step" }; export interface UrlParts { search: string } +export interface HashParts { route: string; app: App | undefined } + +// The flag rides in the fragment, which is never sent to the service nor carried in a Referer. +export function readHash(hash: string): HashParts { + const at = hash.indexOf("?"); + if (at < 0) return { route: hash, app: undefined }; + const flag = new URLSearchParams(hash.slice(at + 1)).get("app"); + return { + route: hash.slice(0, at), + app: flag === "true" ? "mobile" : flag === "desktop" ? "desktop" : undefined, + }; +} + +// A bearer token: whoever reads this URL can redeem the badge. +export function appCodeLink(code: string): string { + return `simplexchat:/badge/code/${encodeURIComponent(code)}`; +} + export function resolveLoad(url: UrlParts, newestOpen: OrderRecord | undefined): Load { const orderId = new URLSearchParams(url.search).get("order"); if (orderId) return { kind: "order", orderId }; diff --git a/apps/simplex-badge-service/web/src/screens.ts b/apps/simplex-badge-service/web/src/screens.ts index 04cb54f756..8ebda80496 100644 --- a/apps/simplex-badge-service/web/src/screens.ts +++ b/apps/simplex-badge-service/web/src/screens.ts @@ -3,7 +3,7 @@ import { badgeIcon, chevronLeft, hamburger, hasBadgeArt, methodMark, wefunderMar import { paymentUri, qrSvg } from "./qr.js"; import type { HistoryRow, UnpaidOrder } from "./order.js"; import type { InvoiceView } from "./api.js"; -import { THEMES, type Method, type Theme } from "./domain.js"; +import { THEMES, type App, type Method, type Theme } from "./domain.js"; import type { CardFailure } from "./stripe.js"; import { CROWDFUNDING_ACTIVE, WEFUNDER_URL, dollars } from "./crowdfunding.js"; @@ -785,9 +785,18 @@ export function windowClosed(o: WindowClosedOptions): HTMLElement { export interface CodeIssuedOptions { code: string; savedLocally: boolean; + app?: App | undefined; } -export function codeIssued(o: CodeIssuedOptions): HTMLElement { +export const REDEEM_IN_SETTINGS = "Settings → Supporter perks → Redeem code"; +export const REDEEM_SCREEN_OPEN = "The Redeem code screen is already open."; + +function codeWithCopy(code: string): HTMLElement[] { + const copy = copyControl("Copy code", code, "primary outline"); + return [el("div", { class: "code" }, code), copy.control, copy.status]; +} + +function codeDetails(o: CodeIssuedOptions): HTMLElement { const onlyCopy = o.savedLocally ? el("div", { class: "warn" }, el("span", { class: "title" }, "This is the only copy."), @@ -796,29 +805,69 @@ export function codeIssued(o: CodeIssuedOptions): HTMLElement { : el("div", { class: "warn" }, el("span", { class: "title" }, "This code could not be saved in this browser."), el("p", {}, "Copy it now. It is shown here and nowhere else.")); - const copy = copyControl("Copy code", o.code, "primary outline"); - const p = panel( - el("div", { class: "tick" }, "✓"), - el("h1", { class: "tight center" }, "Paid. Here is your code."), - el("div", { class: "code" }, o.code), - copy.control, - copy.status, - ); - const qr = qrFigure(o.code, "Badge code as a scannable code", "scan to carry it to your phone"); - const details = el("div", { class: "details" }, + return el("div", { class: "details" }, el("div", { class: "rows plain" }, el("span", { class: "label" }, "Redeem it in the app"), - el("div", {}, "Settings → Supporter perks → Redeem code"), + el("div", {}, o.app === "desktop" ? REDEEM_SCREEN_OPEN : REDEEM_IN_SETTINGS), ), onlyCopy, ); +} + +export function codeIssued(o: CodeIssuedOptions): HTMLElement { + const p = panel( + el("div", { class: "tick" }, "✓"), + el("h1", { class: "tight center" }, "Paid. Here is your code."), + ...codeWithCopy(o.code), + ); + const qr = qrFigure(o.code, "Badge code as a scannable code", "scan to carry it to your phone"); const split = el("div", { class: "split" }); if (qr !== null) split.append(qr); - split.append(details); + split.append(codeDetails(o)); p.append(split); return p; } +export const RETURN_TO_APP = "Return to SimpleX"; +export const SHOW_CODE = "Show code"; + +export type AppCode = + | { kind: "hidden"; onShow: () => void } + | { kind: "shown"; code: string; savedLocally: boolean }; + +export interface ReturnToAppOptions { + onReturn: () => void; + code: AppCode; +} + +// The buyer is on the phone the code is for, so the revealed code comes without the QR that would carry it there. +export function returnToApp(o: ReturnToAppOptions): HTMLElement { + const p = panel( + el("div", { class: "tick" }, "✓"), + el("h1", { class: "tight" }, "Paid"), + el("p", { class: "lede" }, + el("span", { class: "line" }, "Your badge is ready."), " ", + el("span", { class: "line" }, "Return to SimpleX to add it."), + ), + button(RETURN_TO_APP, o.onReturn), + ); + p.classList.add("ending"); + switch (o.code.kind) { + case "hidden": + p.append(el("p", { class: "row-line center" }, button(SHOW_CODE, o.code.onShow, "link"))); + return p; + case "shown": + p.append(...codeWithCopy(o.code.code), el("div", { class: "split" }, codeDetails(o.code))); + return p; + } +} + +// A refused or unhandled scheme fails inside this frame, so no error page replaces the screen and no entry +// joins the history; the frame's first navigation replaces its about:blank. +export function appLauncher(link: string): HTMLIFrameElement { + return el("iframe", { src: link, hidden: "", "aria-hidden": "true", tabindex: "-1" }); +} + const MONTHS: readonly string[] = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", diff --git a/apps/simplex-badge-service/web/src/store.ts b/apps/simplex-badge-service/web/src/store.ts index 767b63e780..339334c77a 100644 --- a/apps/simplex-badge-service/web/src/store.ts +++ b/apps/simplex-badge-service/web/src/store.ts @@ -1,5 +1,5 @@ import { - composeOrder, readChain, readMethod, readStatus, readStep, readTheme, + composeOrder, readApp, readChain, readMethod, readStatus, readStep, readTheme, type OrderRecord, type SessionRecord, type Theme, } from "./domain.js"; import { asObject, filledText, flag, positiveInteger, text, nonNegativeInteger } from "./parse.js"; @@ -36,7 +36,7 @@ function readOrder(value: unknown): OrderRecord | undefined { badgeType: text(o.badgeType) ?? "", months: positiveInteger(o.months) ?? 0, }, - { code: text(o.code), submitted: flag(o.submitted), canceled: flag(o.canceled), method: readMethod(o.method) }, + { code: text(o.code), submitted: flag(o.submitted), canceled: flag(o.canceled), method: readMethod(o.method), app: readApp(o.app) }, { status, amount: positiveInteger(o.amount), @@ -61,9 +61,11 @@ function readSession(value: unknown): SessionRecord | undefined { const priceId = text(s.priceId); const offerId = text(s.offerId); const method = readMethod(s.method); + const app = readApp(s.app); if (priceId !== undefined) session.priceId = priceId; if (offerId !== undefined) session.offerId = offerId; if (method !== undefined) session.method = method; + if (app !== undefined) session.app = app; return session; } @@ -119,7 +121,7 @@ export class Store { return list.flatMap((o) => readOrder(o) ?? []); } - // The record replaces the stored entry, but the browser-only fields (code, submitted, canceled) are preserved because the service never sends them back. + // The record replaces the stored entry, but the browser-only fields (code, submitted, canceled, app) are preserved because the service never sends them back. saveOrder(record: OrderRecord): boolean { const list = this.orders(); const at = list.findIndex((o) => o.orderId === record.orderId); @@ -130,6 +132,7 @@ export class Store { ...(record.code === undefined && kept.code !== undefined ? { code: kept.code } : {}), ...(kept.submitted === true ? { submitted: true } : {}), ...(kept.canceled === true ? { canceled: true } : {}), + ...(record.app === undefined && kept.app !== undefined ? { app: kept.app } : {}), }; } else { if (list.length >= CAP) { diff --git a/apps/simplex-badge-service/web/test/main-app-desktop.test.ts b/apps/simplex-badge-service/web/test/main-app-desktop.test.ts new file mode 100644 index 0000000000..4c6d956cb0 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-desktop.test.ts @@ -0,0 +1,50 @@ +// Opened by the desktop app, which has no scheme handler: the code is shown as ever, and the app has Redeem code open beside it. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, timedTest, until } from "./boot.js"; +import type { StubElement } from "./stub-dom.js"; + +const appTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ url: "/#/tier?app=desktop" }); +const { app, history, storage, fetches } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); +const inView = (): StubElement => inViewOf(app); + +appTest("main: #/tier?app=desktop lands on Choose your badge", () => { + assert.equal(headingOf(inView()), "Choose your badge"); + assert.equal(history.url, "/#/tier"); +}); + +appTest("main: paid from the desktop app, the code screen says Redeem code is already open", async () => { + inView().all("button.choice").find((c) => c.textContent.startsWith("Supporter"))!.click(); + primaryOf(inView())!.click(); + inView().all("button.choice").find((c) => c.textContent.startsWith("1 month"))!.click(); + primaryOf(inView())!.click(); + page.respondWith({ status: 200, body: { + invoiceId: "inv_desk", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "0.02", cryptoCurrency: "xmr", + } }); + inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => heading() === "Send 0.02 XMR", `the payment screen, not ${heading()}`); + for (const f of fetches) { + const sent = `${f.url} ${String(f.init?.body ?? "")}`; + assert.ok(!/app=|"app"|desktop/.test(sent), `no request names the app: ${sent}`); + } + + page.answerHeld({ status: 200, body: { + status: "paid", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + paidInFull: true, settledAt: "2026-08-28T12:05:00Z", + } }, "/api/invoice/inv_desk"); + await until(() => heading() === "Paid. Here is your code.", `the code screen, not ${heading()}`); + + const code = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<{ code: string }>)[0]!.code; + assert.equal(screenOf(app).all("div.code")[0]!.textContent, code, "the code, shown as it always was"); + assert.ok(screenOf(app).textContent.includes("The Redeem code screen is already open.")); + assert.ok(!screenOf(app).textContent.includes("Settings → Supporter perks"), "the one changed line"); + assert.equal(screenOf(app).all("iframe").length, 0, "desktop has no scheme to try"); +}); diff --git a/apps/simplex-badge-service/web/test/main-app-mobile.test.ts b/apps/simplex-badge-service/web/test/main-app-mobile.test.ts new file mode 100644 index 0000000000..40c5682a48 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-mobile.test.ts @@ -0,0 +1,119 @@ +// Opened by the mobile app: the code leaves by the simplexchat: link, and the page shows it only when asked. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, settle, timedTest, until } from "./boot.js"; +import type { StubElement } from "./stub-dom.js"; + +const appTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ url: "/#/tier?app=true" }); +const { app, history, location, storage, fetches } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); +const inView = (): StubElement => inViewOf(app); +const stored = (): Record => + (JSON.parse(storage.getItem("sb.orders.v1") ?? "[]") as Array>)[0] ?? {}; + +appTest("main: #/tier?app=true lands on Choose your badge, with the flag in the session and off the URL", () => { + assert.equal(headingOf(inView()), "Choose your badge"); + assert.equal(history.url, "/#/tier", "the tail is dropped once read"); + assert.deepEqual(history.stack, ["/", "/#/tier"], "and Back still reaches the landing"); + assert.equal((JSON.parse(storage.getItem("sb.session.v1")!) as { app?: unknown }).app, "mobile"); +}); + +appTest("main: the flag survives the steps and is stamped on the order, and no request carries it", async () => { + inView().all("button.choice").find((c) => c.textContent.startsWith("Legend"))!.click(); + primaryOf(inView())!.click(); + inView().all("button.choice").find((c) => c.textContent.startsWith("12 months"))!.click(); + primaryOf(inView())!.click(); + assert.equal(headingOf(inView()), "Check your order"); + + page.respondWith({ status: 200, body: { + invoiceId: "inv_mob", badgeType: "legend", months: 12, amount: 42000, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "1.482", cryptoCurrency: "xmr", + } }); + inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => heading() === "Send 1.482 XMR", `the payment screen, not ${heading()}`); + + assert.equal(location.search, "?order=inv_mob"); + assert.equal(stored().app, "mobile", "the order carries the flag across the ?order= round trip"); + assert.equal(storage.getItem("sb.session.v1"), null, "the session that carried it here is done"); + const created = fetches.find((f) => f.url === "/api/invoice")!; + assert.deepEqual(Object.keys(JSON.parse(String(created.init!.body)) as object).sort(), + ["codeHash", "method", "offerId", "priceId"], "the service is told nothing about the app"); + for (const f of fetches) { + const sent = `${f.url} ${String(f.init?.body ?? "")}`; + assert.ok(!/app=|"app"|mobile/.test(sent), `no request names the app: ${sent}`); + } +}); + +appTest("main: once paid, the ending tries the link once and holds the code back", async () => { + page.answerHeld({ status: 200, body: { + status: "paid", badgeType: "legend", months: 12, amount: 42000, currency: "usd", + paidInFull: true, settledAt: "2026-08-28T12:05:00Z", + } }, "/api/invoice/inv_mob"); + await until(() => heading() === "Paid", `the return-to-app ending, not ${heading()}`); + + const code = stored().code as string; + const link = `simplexchat:/badge/code/${code}`; + const frames = screenOf(app).all("iframe"); + assert.equal(frames.length, 1, "one automatic attempt"); + assert.equal(frames[0]!.getAttribute("src"), link); + assert.ok(frames[0]!.hasAttribute("hidden"), "a refusal stays inside a frame nobody sees"); + assert.equal(screenOf(app).serialize().split(code).length - 1, 1, "the frame's src is the only place the code appears"); + assert.ok(!screenOf(app).textContent.includes(code), "and none of it is text on the screen"); +}); + +appTest("main: a repaint keeps the ending but does not fire the link again", async () => { + page.setOffline(true); + await settle(); + page.setOffline(false); + await settle(); + assert.equal(heading(), "Paid"); + assert.equal(screenOf(app).all("iframe").length, 0); +}); + +appTest("main: with the automatic attempt refused, Return to SimpleX still navigates, off the page's history", () => { + const before = [...history.stack]; + screenOf(app).all("button.primary").find((b) => b.textContent === "Return to SimpleX")!.click(); + assert.equal((location as { href?: string }).href, `simplexchat:/badge/code/${stored().code as string}`); + assert.deepEqual(history.stack, before, "the page adds no history entry of its own"); + assert.equal(heading(), "Paid", "and the screen is still there to fall back on"); +}); + +appTest("main: Show code reveals the stored code in place, keeps Return to SimpleX, and it stays revealed", async () => { + screenOf(app).all("button.link").find((b) => b.textContent === "Show code")!.click(); + const code = stored().code as string; + const revealed = (): void => { + assert.equal(heading(), "Paid", "still the ending, not the plain code screen"); + assert.equal(screenOf(app).all("div.code")[0]!.textContent, code); + assert.equal(screenOf(app).all("svg").filter((s) => s.getAttribute("class") === "qr").length, 0, "no QR"); + assert.ok(screenOf(app).all("button.primary").some((b) => b.textContent === "Return to SimpleX")); + }; + revealed(); + assert.equal(screenOf(app).all("iframe").length, 0, "revealing does not fire the link again"); + + page.clipboard.writes.length = 0; + screenOf(app).all("button.primary").find((b) => b.textContent === "Copy code")!.click(); + await settle(); + assert.deepEqual(page.clipboard.writes, [code]); + + page.setOffline(true); + await settle(); + page.setOffline(false); + await settle(); + revealed(); + + screenOf(app).all("button.primary").find((b) => b.textContent === "Return to SimpleX")!.click(); + assert.equal((location as { href?: string }).href, `simplexchat:/badge/code/${code}`, "the button still works once the code is out"); +}); + +appTest("main: nothing the page wrote to history or storage outside the order holds the link", () => { + for (const url of history.stack) assert.ok(!url.includes("simplexchat") && !url.includes("SB-"), url); + for (const [key, value] of storage.m) { + if (key !== "sb.orders.v1") assert.ok(!value.includes("SB-") && !value.includes("simplexchat"), key); + } +}); diff --git a/apps/simplex-badge-service/web/test/main-app-reload.test.ts b/apps/simplex-badge-service/web/test/main-app-reload.test.ts new file mode 100644 index 0000000000..f372dd7ec8 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-reload.test.ts @@ -0,0 +1,31 @@ +// A reload mid-purchase: the flag is off the URL by now, so only the session can still carry it to the order. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, timedTest, until } from "./boot.js"; +import { MemStorage } from "./stub-dom.js"; + +const reloadTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +const storage = new MemStorage(); +storage.setItem("sb.session.v1", JSON.stringify({ step: "months", priceId: "price_legend", offerId: "offer_12m", app: "mobile" })); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ storage, url: "/#/months" }); +const { app } = page; +await import("../src/main.js"); + +reloadTest("main: after a reload the session still carries the flag, and the order it makes is stamped", async () => { + assert.equal(headingOf(inViewOf(app)), "How long?"); + assert.equal((JSON.parse(storage.getItem("sb.session.v1")!) as { app?: unknown }).app, "mobile"); + + primaryOf(inViewOf(app))!.click(); + page.respondWith({ status: 200, body: { + invoiceId: "inv_reload", badgeType: "legend", months: 12, amount: 42000, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "1.482", cryptoCurrency: "xmr", + } }); + inViewOf(app).all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => headingOf(screenOf(app)) === "Send 1.482 XMR", "the payment screen"); + const order = (JSON.parse(storage.getItem("sb.orders.v1")!) as Array<{ app?: string }>)[0]!; + assert.equal(order.app, "mobile"); +}); diff --git a/apps/simplex-badge-service/web/test/main-app-return.test.ts b/apps/simplex-badge-service/web/test/main-app-return.test.ts new file mode 100644 index 0000000000..cfb53206b4 --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-return.test.ts @@ -0,0 +1,38 @@ +// A mobile-app order comes back from Stripe with neither the query nor the fragment, so the ending is read off the order alone. +// This is also the reload after Show code: a fresh load puts the ending back, with the code one tap away. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, installPage, screenOf, timedTest, until } from "./boot.js"; +import { MemStorage } from "./stub-dom.js"; + +const returnTest = timedTest(3000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); +const CODE = "SB-YDC8A-YGQTM-PUYZ9-2TUXP"; + +const storage = new MemStorage(); +storage.setItem("sb.orders.v1", JSON.stringify([{ + orderId: "inv_card", badgeType: "supporter", months: 1, + createdAt: new Date(NOW - 60_000).toISOString(), status: "paid", paidInFull: true, + submitted: true, code: CODE, amount: 700, currency: "usd", method: "card", app: "mobile", +}])); +storage.setItem("sb.cardReturn.v1", JSON.stringify({ orderId: "inv_card", at: NOW - 5_000 })); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ storage, url: "/?payment_intent=pi_1&redirect_status=succeeded" }); +const { app } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); + +returnTest("main: back from Stripe, the order's own flag picks the return-to-app ending", async () => { + await until(() => heading() === "Paid", `the return-to-app ending, not ${heading()}`); + assert.equal(page.location.search, "?order=inv_card"); + assert.equal(screenOf(app).all("iframe")[0]!.getAttribute("src"), `simplexchat:/badge/code/${CODE}`); + assert.ok(!screenOf(app).textContent.includes(CODE)); +}); + +returnTest("main: Show code reveals the stored code", () => { + screenOf(app).all("button.link").find((b) => b.textContent === "Show code")!.click(); + assert.equal(heading(), "Paid"); + assert.equal(screenOf(app).all("div.code")[0]!.textContent, CODE); +}); diff --git a/apps/simplex-badge-service/web/test/main-app-unsaved.test.ts b/apps/simplex-badge-service/web/test/main-app-unsaved.test.ts new file mode 100644 index 0000000000..ddead5d2ef --- /dev/null +++ b/apps/simplex-badge-service/web/test/main-app-unsaved.test.ts @@ -0,0 +1,49 @@ +// Opened by the mobile app, but every stored row already holds a code, so the new order's code cannot be saved. +import { mock } from "node:test"; +import assert from "node:assert/strict"; +import { headingOf, inViewOf, installPage, primaryOf, screenOf, timedTest, until } from "./boot.js"; +import { MemStorage, type StubElement } from "./stub-dom.js"; + +const unsavedTest = timedTest(5000); +const NOW = Date.parse("2026-08-28T12:00:00Z"); + +const full = new MemStorage(); +full.m.set("sb.orders.v1", JSON.stringify( + Array.from({ length: 50 }, (_, i) => ({ + orderId: `inv_old_${i}`, badgeType: "supporter", months: 1, + createdAt: new Date(NOW - (i + 2) * 86_400_000).toISOString(), status: "paid", paidInFull: true, + code: `SB-OLD${String(i).padStart(2, "0")}-YGQTM-PUYZ9-2TUXP`, amount: 700, currency: "usd", + })), +)); + +mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); +const page = installPage({ storage: full, url: "/#/tier?app=true" }); +const { app } = page; +await import("../src/main.js"); + +const heading = (): string => headingOf(screenOf(app)); +const inView = (): StubElement => inViewOf(app); + +unsavedTest("main: a code this browser could not save is shown at once, not held back behind the link", async () => { + inView().all("button.choice").find((c) => c.textContent.startsWith("Supporter"))!.click(); + primaryOf(inView())!.click(); + inView().all("button.choice").find((c) => c.textContent.startsWith("1 month"))!.click(); + primaryOf(inView())!.click(); + page.respondWith({ status: 200, body: { + invoiceId: "inv_unsaved", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + expiresAt: "2026-08-28T12:58:12Z", address: "48HqK2Xm", cryptoAmount: "0.02", cryptoCurrency: "xmr", + } }); + inView().all("button.primary").find((b) => b.textContent.startsWith("Pay"))!.click(); + await until(() => heading() === "Send 0.02 XMR", `the payment screen, not ${heading()}`); + + page.answerHeld({ status: 200, body: { + status: "paid", badgeType: "supporter", months: 1, amount: 700, currency: "usd", + paidInFull: true, settledAt: "2026-08-28T12:05:00Z", + } }, "/api/invoice/inv_unsaved"); + await until(() => heading() === "Paid. Here is your code.", `the plain code screen, not ${heading()}`); + + assert.match(screenOf(app).all("div.code")[0]!.textContent, /^SB-[0-9A-Z]{5}-[0-9A-Z]{5}-[0-9A-Z]{5}-[0-9A-Z]{5}$/); + assert.ok(screenOf(app).textContent.includes("This code could not be saved in this browser.")); + assert.equal(screenOf(app).all("iframe").length, 0, "no link is tried"); + assert.equal(screenOf(app).all("button").filter((b) => b.textContent === "Show code").length, 0); +}); diff --git a/apps/simplex-badge-service/web/test/main-card-full-store.test.ts b/apps/simplex-badge-service/web/test/main-card-full-store.test.ts index bad13b57ef..57cc836222 100644 --- a/apps/simplex-badge-service/web/test/main-card-full-store.test.ts +++ b/apps/simplex-badge-service/web/test/main-card-full-store.test.ts @@ -22,7 +22,7 @@ mock.timers.enable({ apis: ["setTimeout", "Date"], now: NOW }); const page = installPage({ storage: full }); const { app } = page; -// The publishable key makes the card path use the real Payment Element, not a stand-in. +// The publishable key is what makes the card path exist at all; with none, it refuses. const keyMeta = new StubElement("meta"); keyMeta.setAttribute("id", "stripe-publishable-key"); keyMeta.setAttribute("name", "stripe-publishable-key"); diff --git a/apps/simplex-badge-service/web/test/main-card-return.test.ts b/apps/simplex-badge-service/web/test/main-card-return.test.ts index 53752dff42..b4277a9b59 100644 --- a/apps/simplex-badge-service/web/test/main-card-return.test.ts +++ b/apps/simplex-badge-service/web/test/main-card-return.test.ts @@ -37,6 +37,9 @@ returnTest("main: a card return resumes the exact order it was for, not the newe assert.ok(codeShape.test(screenOf(app).serialize()), "the code the buyer paid for is on it"); assert.ok(screenOf(app).serialize().includes(CODE), "and it is the code of inv_card, the order that was paid"); assert.ok(!screenOf(app).serialize().includes("48HqK2Xmv"), "not the newer open order's address"); + assert.ok(screenOf(app).textContent.includes("Settings → Supporter perks → Redeem code"), + "an order bought with no app flag ends as the page always has"); + assert.equal(screenOf(app).all("iframe").length, 0, "with no link to try"); }); returnTest("main: the return is spent — the URL is the order and the remembered id is cleared", () => { diff --git a/apps/simplex-badge-service/web/test/routing.test.ts b/apps/simplex-badge-service/web/test/routing.test.ts index 67ff1c2151..f4f704505a 100644 --- a/apps/simplex-badge-service/web/test/routing.test.ts +++ b/apps/simplex-badge-service/web/test/routing.test.ts @@ -1,6 +1,6 @@ import { timedTest } from "./boot.js"; import assert from "node:assert/strict"; -import { resolveLoad, screenForOpenOrder } from "../src/routing.js"; +import { appCodeLink, readHash, resolveLoad, screenForOpenOrder } from "../src/routing.js"; import { type OrderRecord } from "../src/domain.js"; import { Store } from "../src/store.js"; @@ -68,3 +68,20 @@ routingTest("routing: the flag survives clearSession, a second order and a reloa assert.equal(reloaded.order("inv_second")!.submitted, undefined, "and it did not spread to the order that followed it"); }); + +routingTest("routing: the app flag is read off the hash's tail, and the route is the hash without it", () => { + assert.deepEqual(readHash("#/tier?app=true"), { route: "#/tier", app: "mobile" }); + assert.deepEqual(readHash("#/tier?app=desktop"), { route: "#/tier", app: "desktop" }); + assert.deepEqual(readHash("#/tier"), { route: "#/tier", app: undefined }, "no tail, no flag"); + assert.deepEqual(readHash(""), { route: "", app: undefined }); +}); + +routingTest("routing: an app value outside the contract is no flag at all", () => { + for (const tail of ["app=", "app=false", "app=TRUE", "app=ios", "other=true"]) { + assert.deepEqual(readHash(`#/tier?${tail}`), { route: "#/tier", app: undefined }, tail); + } +}); + +routingTest("routing: the link back to the app is the simplexchat: scheme with the code as its last segment", () => { + assert.equal(appCodeLink("SB-YDC8A-YGQTM-PUYZ9-2TUXP"), "simplexchat:/badge/code/SB-YDC8A-YGQTM-PUYZ9-2TUXP"); +}); diff --git a/apps/simplex-badge-service/web/test/screens.test.ts b/apps/simplex-badge-service/web/test/screens.test.ts index efe74e6535..50370be92b 100644 --- a/apps/simplex-badge-service/web/test/screens.test.ts +++ b/apps/simplex-badge-service/web/test/screens.test.ts @@ -688,6 +688,66 @@ domTest("screens: the code screen is the one screen that prints a code, with the assert.deepEqual(copied, [HELD_CODE]); }); +domTest("screens: opened from the desktop app, the code screen says the Redeem code screen is already open", () => { + const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: true, app: "desktop" })); + assert.ok(p.textContent.includes("Redeem it in the app")); + assert.ok(p.textContent.includes(screens.REDEEM_SCREEN_OPEN)); + assert.ok(!p.textContent.includes(screens.REDEEM_IN_SETTINGS), "one way to redeem, the one already on screen"); + assert.ok(p.textContent.includes(HELD_CODE), "and the code itself is unchanged"); +}); + +domTest("screens: the return-to-app ending opens on the tick, offers the link and Show code, and prints no code", () => { + let returned = 0; + let shown = 0; + const p = render(screens.returnToApp({ + onReturn: () => { returned += 1; }, + code: { kind: "hidden", onShow: () => { shown += 1; } }, + })); + assert.equal(p.children[0], p.all("div.tick")[0], "the payment went through, and this is the first thing that says so"); + assert.equal(p.all("div.tick")[0]!.textContent, "✓"); + assert.equal(p.all("h1")[0]!.textContent, "Paid"); + assert.ok(p.textContent.includes("Your badge is ready.")); + assert.ok(p.textContent.includes("Return to SimpleX to add it.")); + assertNoCode(p, "the return-to-app ending"); + p.all("button.primary").find((b) => b.textContent === screens.RETURN_TO_APP)!.click(); + p.all("button.link").find((b) => b.textContent === screens.SHOW_CODE)!.click(); + assert.deepEqual([returned, shown], [1, 1]); +}); + +domTest("screens: Show code reveals the code in place, under a Return to SimpleX that stays, and with no QR", () => { + copied.length = 0; + let returned = 0; + const p = render(screens.returnToApp({ + onReturn: () => { returned += 1; }, + code: { kind: "shown", code: HELD_CODE, savedLocally: true }, + })); + assert.equal(p.all("h1")[0]!.textContent, "Paid", "the ending stays the ending"); + assert.equal(p.all("div.tick").length, 1); + assert.equal(p.all("div.code")[0]!.textContent, HELD_CODE); + assert.equal(p.all("button").filter((b) => b.textContent === screens.SHOW_CODE).length, 0, "Show code has given way"); + assert.equal(p.all("svg").filter((s) => s.getAttribute("class") === "qr").length, 0, + "the buyer is already on the phone, so there is nothing to scan it to"); + assert.ok(!p.textContent.includes("scan to carry it to your phone")); + for (const line of ["Redeem it in the app", screens.REDEEM_IN_SETTINGS, "This is the only copy.", + "Saved in this browser and nowhere else."]) { + assert.ok(p.textContent.includes(line), `the revealed code is missing: ${line}`); + } + + p.all("button.primary").find((b) => b.textContent === "Copy code")!.click(); + assert.deepEqual(copied, [HELD_CODE], "the copy control copies the code"); + p.all("button.primary").find((b) => b.textContent === screens.RETURN_TO_APP)!.click(); + assert.equal(returned, 1, "and the link is still one tap away"); +}); + +domTest("screens: the launcher is a hidden frame out of the tab order, pointed at the link", () => { + const f = render(screens.appLauncher("simplexchat:/badge/code/X")); + assert.equal(f.tagName, "iframe"); + assert.equal(f.getAttribute("src"), "simplexchat:/badge/code/X"); + assert.ok(f.hasAttribute("hidden")); + assert.equal(f.getAttribute("aria-hidden"), "true"); + assert.equal(f.getAttribute("tabindex"), "-1"); +}); + domTest("screens: the code screen drops its saved-copy clause where the local write failed", () => { const p = render(screens.codeIssued({ code: HELD_CODE, savedLocally: false })); assert.ok(p.textContent.includes("This code could not be saved in this browser.")); diff --git a/apps/simplex-badge-service/web/test/store.test.ts b/apps/simplex-badge-service/web/test/store.test.ts index d4e8c5a8c4..08ce22b8f5 100644 --- a/apps/simplex-badge-service/web/test/store.test.ts +++ b/apps/simplex-badge-service/web/test/store.test.ts @@ -190,6 +190,27 @@ storeTest("store: the two facts only this browser holds survive a write that omi assert.equal(s.order("1")!.submitted, true); }); +storeTest("store: the app that opened the page round-trips the session, and a value outside the contract reads as none", () => { + const mem = new MemoryStorage(); + const s = new Store(mem); + s.saveSession({ app: "mobile" }); + s.saveSession({ step: "months", priceId: "price_legend" }); + assert.equal(new Store(mem).session().app, "mobile", "a later step and a reload keep it"); + mem.setItem("sb.session.v1", JSON.stringify({ step: "tier", app: "true" })); + assert.equal(s.session().app, undefined, "the stored value is the page's own word, never the URL's"); +}); + +storeTest("store: an order keeps the app it was bought from through a write that omits it and a reload", () => { + const mem = new MemoryStorage(); + const s = new Store(mem); + s.saveOrder(order("1", { app: "desktop" })); + s.saveOrder(order("1", { status: "paid" })); + assert.equal(s.order("1")!.app, "desktop", "the service never sends it, so its answer cannot clear it"); + assert.equal(new Store(mem).order("1")!.app, "desktop"); + s.saveOrder(order("2")); + assert.equal(s.order("2")!.app, undefined, "and it is not a page-wide flag"); +}); + storeTest("store: markSubmitted is per order, sticky, and survives everything but Forget", () => { const mem = new MemoryStorage(); const s = new Store(mem); diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md index 028e470964..d8985700cd 100644 --- a/apps/simplex-calculator-bot/README.md +++ b/apps/simplex-calculator-bot/README.md @@ -25,7 +25,7 @@ The bot prints its address and keeps its data in `./data`. To add a SimpleX name to the address, register the name with the address short link, and run `npm start -- --domain yourname.simplex`. The name is kept when the bot starts without `--domain`. -To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`. +To use the library from this repository, build it and run `npm install --no-save ../../packages/simplex-chat-nodejs` instead of `npm install`, then start the bot with `SIMPLEX_ADDON_PATH=../../packages/simplex-chat-nodejs/build/Release/simplex.node`. ## Test diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index f771199dea..228c9329c2 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -12,4 +12,4 @@ main = do opts@DirectoryOpts {runCLI} <- welcomeGetOpts if runCLI then directoryServiceCLI opts - else directoryService opts terminalChatConfig + else newServiceState opts >>= directoryService opts terminalChatConfig diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index bc84ab86e1..7d6fc609b0 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -66,6 +66,7 @@ data DirectoryEvent | DEItemEditIgnored Contact | DEItemDeleteIgnored Contact | DEContactCommand Contact ChatItemId ADirectoryCmd + | DEVoiceUploadEnded FilePath | DELogChatResponse Text deriving (Show) @@ -110,11 +111,17 @@ crDirectoryEvent_ = \case where ciId = chatItemId' ci err = ADC SDRUser DCUnknownCommand + CEvtSndFileCompleteXFTP {chatItem, fileTransferMeta} -> voiceUploadEnded (Just chatItem) fileTransferMeta + CEvtSndFileError {chatItem_, fileTransferMeta} -> voiceUploadEnded chatItem_ fileTransferMeta CEvtMessageError {severity, errorMessage} -> Just $ DELogChatResponse $ "message error: " <> severity <> ", " <> errorMessage CEvtChatErrors {chatErrors} -> Just $ DELogChatResponse $ "chat errors: " <> T.intercalate ", " (map tshow chatErrors) _ -> Nothing where pending m = memberStatus m == GSMemPendingApproval + voiceUploadEnded :: Maybe AChatItem -> FileTransferMeta -> Maybe DirectoryEvent + voiceUploadEnded ci_ FileTransferMeta {filePath} = case ci_ of + Just (AChatItem _ SMDSnd _ ChatItem {content = CISndMsgContent MCVoice {}}) -> Just $ DEVoiceUploadEnded filePath + _ -> Nothing data DirectoryRole = DRUser | DRAdmin | DRSuperUser diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs index dd7bbb509a..65b47d6c29 100644 --- a/apps/simplex-directory-service/src/Directory/Listing.hs +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -25,6 +25,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy as LB import Data.Int (Int64) import Data.List (isPrefixOf) +import Data.List.NonEmpty (NonEmpty) import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -92,7 +93,9 @@ data DirectoryEntry = DirectoryEntry simplexName :: Maybe Text, groupLink :: PublicLink, shortDescr :: Maybe MarkdownList, + shortDescrBlocks :: Maybe (NonEmpty MarkdownBlock), welcomeMessage :: Maybe MarkdownList, + welcomeMessageBlocks :: Maybe (NonEmpty MarkdownBlock), imageFile :: Maybe String, activeAt :: Maybe UTCTime, createdAt :: Maybe UTCTime @@ -133,6 +136,8 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma linkLine = groupLinkLine displayName $ decodeUtf8 linkText in Just $ maybe linkLine (<> "\n\n" <> linkLine) description _ -> description + shortDescr' = toFormattedText <$> shortDescr + welcomeMessage' = toFormattedText <$> description' entry groupLink = let de = DirectoryEntry @@ -140,8 +145,10 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma displayName, simplexName = shortNameInfoStr . SimplexNameInfo NTPublicGroup <$> verifiedGroupDomain g, groupLink, - shortDescr = toFormattedText <$> shortDescr, - welcomeMessage = toFormattedText <$> description', + shortDescr = fst <$> shortDescr', + shortDescrBlocks = snd =<< shortDescr', + welcomeMessage = fst <$> welcomeMessage', + welcomeMessageBlocks = snd =<< welcomeMessage', imageFile = fst <$> imgData, activeAt = recentRoundedTime 900 now $ fromMaybe createdAt chatTs, createdAt = recentRoundedTime 86400 now createdAt @@ -194,5 +201,5 @@ generateListing dir gs = do where saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map fst -toFormattedText :: Text -> MarkdownList -toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t +toFormattedText :: Text -> (MarkdownList, Maybe (NonEmpty MarkdownBlock)) +toFormattedText t = let ParsedMarkdown ft blocks = parsedMarkdown t in (fromMaybe [FormattedText Nothing t] ft, blocks) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index f10cbf44fd..7d4efb0497 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -12,7 +12,9 @@ {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Service - ( welcomeGetOpts, + ( ServiceState (..), + welcomeGetOpts, + newServiceState, directoryService, directoryServiceCLI, ) @@ -245,9 +247,8 @@ directoryCommands = where idParam = Just "" -directoryService :: DirectoryOpts -> ChatConfig -> IO () -directoryService opts cfg = do - env@ServiceState {eventQ} <- newServiceState opts +directoryService :: DirectoryOpts -> ChatConfig -> ServiceState -> IO () +directoryService opts cfg env@ServiceState {eventQ} = do let chatHooks = defaultChatHooks { preStartHook = Just $ directoryPreStartHook opts, @@ -340,6 +341,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o SDRUser -> deUserCommand ct ciId cmd SDRAdmin -> deAdminCommand ct ciId cmd SDRSuperUser -> deSuperUserCommand ct ciId cmd + DEVoiceUploadEnded filePath -> void (try $ removeFile filePath :: IO (Either SomeException ())) DELogChatResponse r -> logInfo r where groupLinkText (CCLink cReq sLnk_) = maybe (strEncodeTxt $ simplexChatContact cReq) strEncodeTxt sLnk_ @@ -651,9 +653,8 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o case voiceResult of Right r -> case lines r of (filePath : durationStr : _) - | not (null filePath), Just duration <- readMaybe durationStr -> do + | not (null filePath), Just duration <- readMaybe durationStr -> sendComposedMessageFile cc sendRef Nothing (MCVoice "" duration) (CF.plain filePath) - void (try $ removeFile filePath :: IO (Either SomeException ())) _ -> logError "voice captcha generator: unexpected output" Left e -> logError $ "voice captcha generator error: " <> tshow e @@ -1156,10 +1157,10 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o -- "Use */filter " <> tshow gId <> " * to change spam filter level: no (disable), basic, moderate, strong.", -- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration." ] - <> ["/'filter " <> tshow gId <> " name' - enable name filter" | isNothing (rejectNames a)] - <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge" | isNothing (passCaptcha a)] - <> ["/'filter " <> tshow gId <> " name captcha' - enable both" | isNothing (rejectNames a) || isNothing (passCaptcha a)] - <> ["/'filter " <> tshow gId <> " off' - disable filter" | isJust (rejectNames a) || isJust (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name' - enable name filter " | isNothing (rejectNames a)] + <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge " | isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name captcha' - enable both " | isNothing (rejectNames a) || isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " off' - disable filter " | isJust (rejectNames a) || isJust (passCaptcha a)] showCondition = \case Nothing -> "_disabled_" Just PCAll -> "_enabled_" diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 98226805bc..02a2d2f5be 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -74,7 +74,7 @@ import Simplex.Chat.Names (claimDomain) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Store import Simplex.Chat.Store.Groups -import Simplex.Chat.Store.Shared (GroupKeysRow, groupInfoQueryFields, groupInfoQueryFrom, mkGroupKeys, toGroupInfo_) +import Simplex.Chat.Store.Shared (GroupKeysRow, groupInfoQueryFields, groupInfoQueryFrom, mkGroupInfoKeys, toGroupInfo_) import Simplex.Chat.Types import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..), SimplexDomain) @@ -316,8 +316,8 @@ getGroupAndRegLink cc user@User {userId, userContactId} gId = (g, gksData, gr, gLink_) <- ExceptT $ firstRow (toGroupInfoKeysRegLink currentTs cxt user) ("group " ++ show gId ++ " not found") $ DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) - gks <- withExceptT groupDBError $ mkGroupKeys db cxt g gksData - pure (GIK g gks, gr, gLink_) + gks <- withExceptT groupDBError $ mkGroupInfoKeys db cxt g gksData + pure (gks, gr, gLink_) where cxt = storeCxt cc diff --git a/apps/simplex-support-bot/README.md b/apps/simplex-support-bot/README.md index 19b9ab8bd6..d95aa5b59b 100644 --- a/apps/simplex-support-bot/README.md +++ b/apps/simplex-support-bot/README.md @@ -12,30 +12,18 @@ A business-address bot that triages incoming support chats, optionally runs them ```bash cd apps/simplex-support-bot -npm install # downloads native libs + transitive deps +npm install npm run build # tsc ``` -By default this installs the **SQLite** backend. +The native library is downloaded on first start into the user cache. To download it ahead of time (for example in a Dockerfile), run as the user that runs the bot: -To use **PostgreSQL** instead, drop a `.npmrc` next to `package.json` *before* `npm install`: - -```bash -echo 'simplex_backend=postgres' > .npmrc -npm install # now pulls postgres-flavored native libs -npm run build +```sh +npx simplex-chat install --backend sqlite +npx simplex-chat install --backend postgres # Linux x86_64 only ``` -`.npmrc` lives next to the package — npm reads it natively, no extra setup. - -### Switching backends - -`npm install` is a no-op for already-installed deps, so editing `.npmrc` and re-running `npm install` will *not* re-trigger `simplex-chat`'s preinstall. To switch backends, force a clean install: - -```bash -rm -rf node_modules -npm install # download-libs.js re-runs and pulls the right native lib -``` +Select the database with `--db sqlite` (default) or `--db postgres`. ## Run @@ -47,11 +35,9 @@ npm start -- --team-group "Support Team" # PostgreSQL npm start -- --team-group "Support Team" \ - --pg-conn "postgres://user:pass@host/db" + --db postgres --pg-conn "postgres://user:pass@host/db" ``` -The bot runs via `npm start` so npm can expose `.npmrc` settings to the process — `detectBackend()` reads `npm_config_simplex_backend` to know which backend was installed. - ## Flags Run `npm start -- --help` for the auto-generated reference. Summary: @@ -60,6 +46,7 @@ Run `npm start -- --help` for the auto-generated reference. Summary: |---|---|---|---|---| | `--team-group` | both | yes | — | team group display name | | `--state-file` | both | no | `./data/state.json` | path to bot state JSON | +| `--db` | both | no | `sqlite` | `sqlite` or `postgres` | | `--sqlite-file-prefix` | sqlite | no | `./data/simplex` | DB file prefix (creates `_chat.db`, `_agent.db`) | | `--sqlite-key` | sqlite | no | (unencrypted) | SQLCipher encryption key | | `--pg-conn` | postgres | yes | — | PostgreSQL connection string | @@ -76,7 +63,8 @@ Run `npm start -- --help` for the auto-generated reference. Summary: | Var | Purpose | |---|---| | `GROK_API_KEY` | xAI API key; enables Grok replies | -| `SIMPLEX_BACKEND` | alternative to `.npmrc` for selecting the install backend (`sqlite` or `postgres`) | +| `SIMPLEX_LIBS_DIR` | directory with a local libsimplex build for the `--db` backend, instead of the downloaded one | +| `SIMPLEX_ADDON_PATH` | locally built Node.js add-on (`simplex.node`), instead of the downloaded one | ## Local development against unreleased lib changes @@ -84,18 +72,18 @@ This package depends on `simplex-chat` from npm. To test against an in-tree vers ```bash # In packages/simplex-chat-nodejs +npm install && npm run build npm link # In apps/simplex-support-bot npm link simplex-chat +export SIMPLEX_ADDON_PATH=../../packages/simplex-chat-nodejs/build/Release/simplex.node ``` `npm unlink simplex-chat && npm install` reverts to the registry version. ## Troubleshooting -- **`--pg-conn is required when backend is postgres`** — the postgres backend is installed but you didn't pass a connection string. +- **`--pg-conn is required with --db postgres`** — pass a connection string, or use `--db sqlite`. - **`libpq5` errors at startup** — install `libpq5` on the host (`apt install libpq5` on Debian/Ubuntu). - **`ENOENT: no such file or directory, open './data/state.json'`** — the parent directory of `--state-file` must exist; `mkdir -p data` before starting. -- **Wrong backend installed** — check `node_modules/simplex-chat/libs/installed.txt`. Edit `.npmrc`, then `rm -rf node_modules && npm install` to switch (`npm install` alone won't re-run the dep's preinstall). -- **`libpq` connection error** at startup with sqlite-flavored config (or vice versa) — `.npmrc` was changed but libs weren't reinstalled. See "Switching backends" above. diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index 37521e49dc..a9785de1e3 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -2403,54 +2403,48 @@ describe("parseConfig Validation", () => { .toThrow(/--complete-hours must be a non-negative integer, got "abc"/) }) - test("postgres backend without --pg-conn → throws", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "postgres" - try { - expect(() => parseConfig(baseArgs)) - .toThrow(/--pg-conn is required when backend is postgres/) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } + test("--db postgres without --pg-conn → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "postgres"])) + .toThrow(/--pg-conn is required with --db postgres/) }) - test("postgres backend with --pg-conn → db is postgres DbConfig", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "postgres" - try { - const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://user:pass@localhost/db"]) - expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"}) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } + test("--db postgres with --pg-conn → db is postgres DbConfig", () => { + const cfg = parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "postgres://user:pass@localhost/db"]) + expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://user:pass@localhost/db"}) }) - test("postgres backend with --pg-schema → DbConfig carries schemaPrefix", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "postgres" - try { - const cfg = parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"]) - expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"}) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } + test("--db postgres with --pg-schema → DbConfig carries schemaPrefix", () => { + const cfg = parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "postgres://localhost/db", "--pg-schema", "bot"]) + expect(cfg.db).toEqual({type: "postgres", connectionString: "postgres://localhost/db", schemaPrefix: "bot"}) }) - test("sqlite backend (default) → db is sqlite DbConfig with default filePrefix", () => { - const prevBackend = process.env.SIMPLEX_BACKEND - const prevNpm = process.env.npm_config_simplex_backend - delete process.env.SIMPLEX_BACKEND - delete process.env.npm_config_simplex_backend - try { - const cfg = parseConfig(baseArgs) - expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"}) - } finally { - if (prevBackend !== undefined) process.env.SIMPLEX_BACKEND = prevBackend - if (prevNpm !== undefined) process.env.npm_config_simplex_backend = prevNpm - } + test("default --db → sqlite DbConfig with default filePrefix", () => { + const cfg = parseConfig(baseArgs) + expect(cfg.db).toEqual({type: "sqlite", filePrefix: "./data/simplex"}) + }) + + test("--pg-conn with sqlite → throws", () => { + expect(() => parseConfig([...baseArgs, "--pg-conn", "postgres://localhost/db"])) + .toThrow(/--pg-conn and --pg-schema require --db postgres/) + }) + + test("--pg-schema with sqlite → throws", () => { + expect(() => parseConfig([...baseArgs, "--pg-schema", "bot"])) + .toThrow(/--pg-conn and --pg-schema require --db postgres/) + }) + + test("--sqlite-key with postgres → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "c", "--sqlite-key", "k"])) + .toThrow(/--sqlite-file-prefix and --sqlite-key require --db sqlite/) + }) + + test("--sqlite-file-prefix with postgres → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "postgres", "--pg-conn", "c", "--sqlite-file-prefix", "./x"])) + .toThrow(/--sqlite-file-prefix and --sqlite-key require --db sqlite/) + }) + + test("invalid --db → throws", () => { + expect(() => parseConfig([...baseArgs, "--db", "mysql"])).toThrow(/Allowed choices are sqlite, postgres/) }) test("sqlite backend with --sqlite-key → DbConfig carries encryptionKey", () => { @@ -2468,18 +2462,6 @@ describe("parseConfig Validation", () => { .toThrow(/required option '--team-group/) }) - test("invalid SIMPLEX_BACKEND → throws", () => { - const prev = process.env.SIMPLEX_BACKEND - process.env.SIMPLEX_BACKEND = "mysql" - try { - expect(() => parseConfig(baseArgs)) - .toThrow(/Invalid SIMPLEX_BACKEND: "mysql"/) - } finally { - if (prev === undefined) delete process.env.SIMPLEX_BACKEND - else process.env.SIMPLEX_BACKEND = prev - } - }) - test("--complete-hours negative → throws", () => { // parseArgs refuses "-1" as a bare arg (ambiguous with a short flag), so use `=` form expect(() => parseConfig([...baseArgs, "--complete-hours", "-1"])) diff --git a/apps/simplex-support-bot/src/config.ts b/apps/simplex-support-bot/src/config.ts index 8fbd006aef..be33db0c38 100644 --- a/apps/simplex-support-bot/src/config.ts +++ b/apps/simplex-support-bot/src/config.ts @@ -1,4 +1,4 @@ -import {Command} from "commander" +import {Command, Option} from "commander" import {api} from "simplex-chat" export interface IdName { @@ -6,8 +6,6 @@ export interface IdName { name: string } -export type Backend = "sqlite" | "postgres" - export interface Config { stateFile: string // local path to the bot's state JSON db: api.DbConfig // passed to ChatApi.init / bot.run @@ -21,18 +19,6 @@ export interface Config { grokApiKey: string | null } -// Mirrors packages/simplex-chat-nodejs/src/download-libs.js so runtime detection -// matches what was used at install time. Works whether the user installed via -// SIMPLEX_BACKEND env var, .npmrc (→ npm_config_simplex_backend), or the -// --simplex_backend=postgres CLI flag (also surfaced as npm_config_*). -export function detectBackend(): Backend { - const raw = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || "sqlite").toLowerCase() - if (raw !== "sqlite" && raw !== "postgres") { - throw new Error(`Invalid SIMPLEX_BACKEND: "${raw}". Must be "sqlite" or "postgres".`) - } - return raw -} - export function parseIdName(s: string): IdName { const i = s.indexOf(":") if (i < 1) throw new Error(`Invalid ID:name format: "${s}"`) @@ -57,21 +43,23 @@ function buildCommand(): Command { .description("business-address triage bot") .requiredOption("--team-group ", "team group display name") .option("--state-file ", "state JSON path", "./data/state.json") + .addOption(new Option("--db ", "database backend").choices(["sqlite", "postgres"]).default("sqlite")) .option("--sqlite-file-prefix ", "SQLite DB file prefix", "./data/simplex") .option("--sqlite-key ", "SQLCipher encryption key (default: unencrypted)") - .option("--pg-conn ", "PostgreSQL connection string (required for postgres)") + .option("--pg-conn ", "PostgreSQL connection string (required with --db postgres)") .option("--pg-schema ", "PostgreSQL schema prefix (default: simplex_v1)") .option("-a, --auto-add-team-members ", "comma-separated ID:name pairs (e.g. 1:Alice,2:Bob)") .option("--timezone ", "IANA timezone for weekend detection", "UTC") .option("--complete-hours ", "auto-complete chats after N hours idle (0 disables)", parseNonNegativeInt("--complete-hours"), 3) .option("--card-flush-seconds ", "debounce card state writes", parseNonNegativeInt("--card-flush-seconds"), 300) .option("--context-file ", "text file with Grok system context (required if GROK_API_KEY set)") - .addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_BACKEND sqlite | postgres — alternative to .npmrc for backend selection\n") + .addHelpText("after", "\nEnvironment:\n GROK_API_KEY xAI API key — enables Grok replies\n SIMPLEX_LIBS_DIR local libsimplex build for the --db backend\n SIMPLEX_ADDON_PATH locally built Node.js add-on\n") } interface RawOpts { teamGroup: string stateFile: string + db: api.DbConfig["type"] sqliteFilePrefix: string sqliteKey?: string pgConn?: string @@ -93,19 +81,19 @@ export function parseConfig(args: string[]): Config { throw err } const opts = cmd.opts() + const given = (option: keyof RawOpts) => cmd.getOptionValueSource(option) === "cli" const grokApiKey = process.env.GROK_API_KEY || null - const backend = detectBackend() let db: api.DbConfig - if (backend === "sqlite") { + if (opts.db === "sqlite") { + if (given("pgConn") || given("pgSchema")) throw new Error("--pg-conn and --pg-schema require --db postgres") db = opts.sqliteKey ? {type: "sqlite", filePrefix: opts.sqliteFilePrefix, encryptionKey: opts.sqliteKey} : {type: "sqlite", filePrefix: opts.sqliteFilePrefix} } else { - if (!opts.pgConn) { - throw new Error("--pg-conn is required when backend is postgres (PostgreSQL connection string)") - } + if (given("sqliteFilePrefix") || given("sqliteKey")) throw new Error("--sqlite-file-prefix and --sqlite-key require --db sqlite") + if (!opts.pgConn) throw new Error("--pg-conn is required with --db postgres") db = opts.pgSchema ? {type: "postgres", connectionString: opts.pgConn, schemaPrefix: opts.pgSchema} : {type: "postgres", connectionString: opts.pgConn} diff --git a/apps/simplex-support-bot/vitest.config.ts b/apps/simplex-support-bot/vitest.config.ts index c143572a87..3a70f6c5e7 100644 --- a/apps/simplex-support-bot/vitest.config.ts +++ b/apps/simplex-support-bot/vitest.config.ts @@ -5,13 +5,6 @@ export default defineConfig({ test: { globals: true, testTimeout: 10000, - // Clear backend signals — .npmrc next to package.json otherwise injects - // npm_config_simplex_backend into every test's env, breaking sqlite-default - // assumptions in parseConfig tests. - env: { - SIMPLEX_BACKEND: "", - npm_config_simplex_backend: "", - }, }, resolve: { alias: { diff --git a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md index 3c3fb7b515..b0e84ada28 100644 --- a/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md +++ b/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md @@ -50,7 +50,7 @@ Other limitations of the desktop app: - you cannot send voice messages. - there is no support for calls yet. -You can download the desktop app for Linux and Mac via [downloads page](../docs/DOWNLOADS.md). Windows version will be available soon. +You can download the desktop app for Linux and Mac via [downloads page](https://simplex.chat/downloads/). Windows version will be available soon. ## Group directory service and other group improvements diff --git a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md index 539d719af4..1a0019216a 100644 --- a/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md +++ b/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md @@ -40,7 +40,7 @@ This is only possible when both devices are connected to the same local network. **On desktop** -If you don't have desktop app installed yet, [download it](../docs/DOWNLOADS.md) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer. +If you don't have desktop app installed yet, [download it](https://simplex.chat/downloads/) and create any chat profile - you don't need to use it, and when you create it there are no server requests sent and no accounts are created. Think about it as about user profile on your computer. Then in desktop app settings choose *Link a mobile* - it will show a QR code. diff --git a/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md b/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md index e06f7c2084..9bd342a146 100644 --- a/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md +++ b/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md @@ -110,7 +110,7 @@ We [wrote previously](https://github.com/simplex-chat/simplexmq/blob/master/rfcs While we cannot require the operators of self-hosted and private servers to disclose any information about them (apart from which server code they use - this is the requirement of the AGPLv3 license to share this information with users connecting to the server), as we add other server operators to the app, it is important for the users to have all important information about these operators and servers location. -This server release adds server information page where all this information can be made available to the users. For example, this is the information about one of the servers preset in the app. +This server release adds server information page where all this information can be made available to the users. For example, this is the information about one of the servers preset in the app. The updated server guide also includes [the instruction](../docs/SERVER.md#) about how to host this page for your server. It is generated as a static page when the server starts. We recommend using Caddy webserver to serve it. diff --git a/blog/20260919-simplex-supporter-badges.md b/blog/20260919-simplex-supporter-badges.md index 7aa2b2e4ac..50e2ebcb4b 100644 --- a/blog/20260919-simplex-supporter-badges.md +++ b/blog/20260919-simplex-supporter-badges.md @@ -11,7 +11,7 @@ permalink: "/blog/20260919-simplex-supporter-badges.html" **Published:** Sep 19, 2026 -You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge. +You can now support SimpleX Chat and get a supporter badge, larger files and longer file storage — from v7.1 beta[^beta]. Watch how to buy a badge. ## A paid feature that cannot identify you @@ -28,7 +28,7 @@ Read more about badges in the [whitepaper](https://github.com/simplex-chat/simpl ## How to get a badge - + Buy a code on [simplex.chat/badges](https://simplex.chat/badges/), paying by card, Bitcoin or Monero, and redeem it in the app: open Settings, tap **Supporter perks**, and enter the code. The badge appears on your profile. The badge does not renew by itself, and no account is created. diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index af7b0146f7..96fe5422cd 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -25,6 +25,7 @@ This file is generated automatically. [Group commands](#group-commands) - [APIAddMember](#apiaddmember) +- [APIAddMemberViaGroup](#apiaddmemberviagroup) - [APIJoinGroup](#apijoingroup) - [APIAcceptMember](#apiacceptmember) - [APIMembersRole](#apimembersrole) @@ -761,6 +762,52 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APIAddMemberViaGroup + +Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. + +*Network usage*: interactive. + +**Parameters**: +- groupId: int64 +- viaGroupId: int64 +- groupMemberId: int64 +- memberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) + +**Syntax**: + +``` +/_add # # relay|observer|author|member|moderator|admin|owner +``` + +```javascript +'/_add #' + groupId + ' #' + viaGroupId + ' ' + groupMemberId + ' ' + memberRole // JavaScript +``` + +```python +'/_add #' + str(groupId) + ' #' + str(viaGroupId) + ' ' + str(groupMemberId) + ' ' + str(memberRole) # Python +``` + +**Responses**: + +SentGroupInvitationViaGroup: Group invitation sent to member of another group. +- type: "sentGroupInvitationViaGroup" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- member: [GroupMember](./TYPES.md#groupmember) +- viaGroupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaMember: [GroupMember](./TYPES.md#groupmember) + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +**Errors**: +- PeerChatVRangeIncompatible: Member's client version is older than required. + +--- + + ### APIJoinGroup Join group. diff --git a/bots/api/EVENTS.md b/bots/api/EVENTS.md index 5416fe4c0e..7c43ce3bd4 100644 --- a/bots/api/EVENTS.md +++ b/bots/api/EVENTS.md @@ -25,6 +25,7 @@ This file is generated automatically. [Group events](#group-events) - Main events - [ReceivedGroupInvitation](#receivedgroupinvitation) + - [ReceivedGroupInvitationViaGroup](#receivedgroupinvitationviagroup) - [UserJoinedGroup](#userjoinedgroup) - [GroupUpdated](#groupupdated) - [JoinedGroupMember](#joinedgroupmember) @@ -286,6 +287,22 @@ Received group invitation. --- +### ReceivedGroupInvitationViaGroup + +Received group invitation from member of another group. + +**Record type**: +- type: "receivedGroupInvitationViaGroup" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaGroupInfo: [GroupInfo](./TYPES.md#groupinfo) +- viaMember: [GroupMember](./TYPES.md#groupmember) +- fromMemberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +- memberRole: [GroupMemberRole](./TYPES.md#groupmemberrole) + +--- + + ### UserJoinedGroup Bot user joined group. Received when connection via group link completes. diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index f1122979a9..7b3985e132 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -18,6 +18,7 @@ This file is generated automatically. - [BadgeServiceErrorCode](#badgeserviceerrorcode) - [BadgeStatus](#badgestatus) - [BadgeType](#badgetype) +- [BlockToken](#blocktoken) - [BlockingInfo](#blockinginfo) - [BlockingReason](#blockingreason) - [BrokerErrorType](#brokererrortype) @@ -131,8 +132,10 @@ This file is generated automatically. - [LinkContent](#linkcontent) - [LinkOwnerSig](#linkownersig) - [LinkPreview](#linkpreview) +- [ListEntry](#listentry) - [LocalBadge](#localbadge) - [LocalProfile](#localprofile) +- [MarkdownBlock](#markdownblock) - [MemberCriteria](#membercriteria) - [MsgChatLink](#msgchatlink) - [MsgContent](#msgcontent) @@ -182,6 +185,7 @@ This file is generated automatically. - [RoleGroupPreference](#rolegrouppreference) - [SMPAgentError](#smpagenterror) - [SecurityCode](#securitycode) +- [SegmentRange](#segmentrange) - [SimplePreference](#simplepreference) - [SimplexDomain](#simplexdomain) - [SimplexDomainClaim](#simplexdomainclaim) @@ -201,6 +205,7 @@ This file is generated automatically. - [SubscriptionStatus](#subscriptionstatus) - [SupportGroupPreference](#supportgrouppreference) - [SwitchPhase](#switchphase) +- [TableAlign](#tablealign) - [TimedMessagesGroupPreference](#timedmessagesgrouppreference) - [TimedMessagesPreference](#timedmessagespreference) - [TransportError](#transporterror) @@ -441,6 +446,9 @@ Remote controller app version range (min and max as version strings). InvalidCode: - type: "invalidCode" +InvalidReceipt: +- type: "invalidReceipt" + ServiceNotConfigured: - type: "serviceNotConfigured" @@ -477,12 +485,14 @@ CredentialNotVerified: - "payment_not_entitled" - "payment_pending" - "provider_unavailable" +- "provider_not_configured" - "rate_limited" - "code_invalid" - "code_used" - "code_expired" - "receipt_invalid" - "receipt_used" +- "test_purchase" - "internal" @@ -508,6 +518,64 @@ CredentialNotVerified: - "investor" +--- + +## BlockToken + +**Discriminated union type**: + +LineBreak: +- type: "lineBreak" + +SoftBreak: +- type: "softBreak" + +HardBreak: +- type: "hardBreak" + +ParagraphBreak: +- type: "paragraphBreak" +- lineBreaks: int + +Indent: +- type: "indent" + +HeadingMark: +- type: "headingMark" +- level: int + +HeadingClose: +- type: "headingClose" + +HeadingUnderline: +- type: "headingUnderline" +- level: int + +ListMark: +- type: "listMark" +- number: string? + +QuoteMark: +- type: "quoteMark" + +FenceMark: +- type: "fenceMark" +- language: string? + +RowMark: +- type: "rowMark" + +CellMark: +- type: "cellMark" + +DelimiterRow: +- type: "delimiterRow" +- columns: [[TableAlign](#tablealign)] + +RuleMark: +- type: "ruleMark" + + --- ## BlockingInfo @@ -1002,6 +1070,7 @@ GroupLink: - sentAt: UTCTime - content: [MsgContent](#msgcontent) - formattedText: [[FormattedText](#formattedtext)]? +- markdownBlocks: [[MarkdownBlock](#markdownblock)]? --- @@ -1500,6 +1569,7 @@ ContactConnection: - content: [CIContent](#cicontent) - mentions: {string : [CIMention](#cimention)} - formattedText: [[FormattedText](#formattedtext)]? +- markdownBlocks: [[MarkdownBlock](#markdownblock)]? - quotedItem: [CIQuote](#ciquote)? - reactions: [[CIReactionCount](#cireactioncount)] - file: [CIFile](#cifile)? @@ -1540,6 +1610,7 @@ Search: - "human" - "bot" - "business" +- "agent" --- @@ -2277,10 +2348,13 @@ NO_FILE: ## Format +The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. + **Discriminated union type**: Bold: - type: "bold" +- singleStar: bool Italic: - type: "italic" @@ -2334,6 +2408,13 @@ Email: Phone: - type: "phone" +Escape: +- type: "escape" + +Markup: +- type: "markup" +- token: [BlockToken](#blocktoken) + --- @@ -2871,6 +2952,16 @@ Unknown: - content: [LinkContent](#linkcontent)? +--- + +## ListEntry + +**Record type**: +- number: string? +- content: [SegmentRange](#segmentrange) +- items: [[ListEntry](#listentry)] + + --- ## LocalBadge @@ -2900,6 +2991,45 @@ Unknown: - contactDomainVerified: bool? +--- + +## MarkdownBlock + +**Discriminated union type**: + +Paragraph: +- type: "paragraph" +- content: [SegmentRange](#segmentrange) + +Heading: +- type: "heading" +- level: int +- content: [SegmentRange](#segmentrange) + +CodeBlock: +- type: "codeBlock" +- language: string? +- content: [SegmentRange](#segmentrange) + +Quote: +- type: "quote" +- content: [SegmentRange](#segmentrange) + +List: +- type: "list" +- items: [[ListEntry](#listentry)] + +Table: +- type: "table" +- columns: [[TableAlign](#tablealign)] +- header: [[SegmentRange](#segmentrange)] +- rows: [[[SegmentRange](#segmentrange)]] + +Rule: +- type: "rule" +- mark: [SegmentRange](#segmentrange) + + --- ## MemberCriteria @@ -3781,6 +3911,15 @@ A_SERVICE: - verifiedAt: UTCTime +--- + +## SegmentRange + +**Record type**: +- from: int +- to: int + + --- ## SimplePreference @@ -4402,6 +4541,17 @@ NoSub: - "completed" +--- + +## TableAlign + +**Enum type**: +- "none" +- "left" +- "center" +- "right" + + --- ## TimedMessagesGroupPreference diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 79820605a8..d13817c439 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -119,6 +119,7 @@ chatCommandsDocsData = ( "Group commands", "Commands to manage and moderate groups. These commands can be used with business chats as well - they are groups. E.g., a common scenario would be to add human agents to business chat with the customer who connected via business address.", [ ("APIAddMember", [], "Add contact to group. Requires bot to have Admin role.", ["CRSentGroupInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_add #" <> Param "groupId" <> " " <> Param "contactId" <> " " <> Param "memberRole"), + ("APIAddMemberViaGroup", [], "Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group.", ["CRSentGroupInvitationViaGroup", "CRChatCmdError"], [TD "CEPeerChatVRangeIncompatible" "Member's client version is older than required"], Just UNInteractive, "/_add #" <> Param "groupId" <> " #" <> Param "viaGroupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), ("APIJoinGroup", ["enableNtfs"], "Join group.", ["CRUserAcceptedGroupSent", "CRChatCmdError"], [], Just UNInteractive, "/_join #" <> Param "groupId"), ("APIAcceptMember", [], "Accept group member. Requires Admin role.", ["CRMemberAccepted", "CRChatCmdError"], [TD "CEGroupMemberNotActive" "Member is not connected yet"], Just UNBackground, "/_accept member #" <> Param "groupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), ("APIMembersRole", [], "Set members role. Requires Admin role.", ["CRMembersRoleUser", "CRChatCmdError"], [], Just UNBackground, "/_member role #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> " " <> Param "memberRole"), @@ -369,6 +370,7 @@ undocumentedCommands = "APIConnectPreparedGroup", "APIContactInfo", "APIContactQueueInfo", + "APICreateBadgeInvoice", "APICreateChatItems", "APICreateChatTag", "APICreateMemberContact", @@ -415,6 +417,7 @@ undocumentedCommands = "APIPlanForwardChatItems", "APIPrepareContact", "APIPrepareGroup", + "APIPurchaseBadge", "APIRedeemBadgeCode", "APIRegisterToken", "APIRejectCall", diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index dab06e9262..a566010823 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -85,6 +85,7 @@ chatEventsDocsData = "Bots may use these events to manage users' groups and business address groups.\n\n\ \*Please note*: programming groups is more complex than programming direct connections", [ ("CEvtReceivedGroupInvitation", ""), + ("CEvtReceivedGroupInvitationViaGroup", "Received group invitation from member of another group."), ("CEvtUserJoinedGroup", "Bot user joined group. Received when connection via group link completes."), ("CEvtGroupUpdated", "Group profile or preferences updated."), ("CEvtJoinedGroupMember", "Another member joined group."), @@ -202,6 +203,7 @@ undocumentedEvents = "CEvtContactRequestRejected", "CEvtContactSwitch", "CEvtCustomChatEvent", + "CEvtDuplicateGroupMessage", "CEvtGroupMemberRatchetSync", "CEvtGroupMemberSwitch", "CEvtServiceSubStatus", @@ -221,6 +223,8 @@ undocumentedEvents = "CEvtSndFileRedirectStartXFTP", "CEvtSndFileStart", -- legacy SMP files "CEvtSndStandaloneFileComplete", + "CEvtStorePurchaseCredited", + "CEvtStorePurchaseRefused", "CEvtConnectionsDiff", "CEvtSubscriptionEnd", "CEvtTerminalEvent", diff --git a/bots/src/API/Docs/Generate/Python.hs b/bots/src/API/Docs/Generate/Python.hs index 64aa1d1062..8d60bced77 100644 --- a/bots/src/API/Docs/Generate/Python.hs +++ b/bots/src/API/Docs/Generate/Python.hs @@ -60,10 +60,7 @@ commandsCodeText = <> respAliasLine where constrName = T.pack $ fstToUpper tag - classDef = - ("class " <> constrName <> "(TypedDict):\n") - <> bodyOrPass (fieldsCodePy " " "T." params) - <> "\n" + classDef = typedDictCode constrName (fieldsCodePy "T." params) <> "\n" cmdStringFunc = ("\ndef " <> constrName <> "_cmd_string(self: " <> constrName <> ") -> str:\n") <> " return " <> pySelfSyntaxText "T." (fstToUpper tag, params) syntax <> "\n" @@ -143,9 +140,7 @@ typesCodeText = "FileProtocol" -> map toUpper m _ -> m typeDefCode = case typeDef of - ATDRecord fields -> - ("\nclass " <> name' <> "(TypedDict):\n") - <> bodyOrPass (fieldsCodePy " " "" fields) + ATDRecord fields -> "\n" <> typedDictCode name' (fieldsCodePy "" fields) ATDEnum cs -> "\n" <> name' <> " = Literal[" <> T.intercalate ", " (map (\m -> "\"" <> T.pack (enumValue m) <> "\"") $ L.toList cs) @@ -231,9 +226,7 @@ unionTypeCodePy memberName typesNamespace name cs = constrTypeRef (ATUnionMember tag _) = memberName name tag tagLiterals = T.intercalate ", " $ map (\(ATUnionMember tag _) -> "\"" <> T.pack tag <> "\"") $ L.toList cs memberClass (ATUnionMember tag fields) = - ("\nclass " <> memberName name tag <> "(TypedDict):\n") - <> (" type: Literal[\"" <> T.pack tag <> "\"]\n") - <> fieldsCodePy " " typesNamespace fields + "\n" <> typedDictCode (memberName name tag) (("type", "Literal[\"" <> T.pack tag <> "\"]", "") : fieldsCodePy typesNamespace fields) -- | Render the right-hand side of a union alias: either inline (one line) or -- multi-line wrapped in parentheses with `|` separators between alternatives. @@ -248,21 +241,27 @@ unionAliasRhs lhs constr cs (h : t) -> h : map ("| " <>) t cs' = map constr cs --- | Emit a body of `pass` if there are no fields, otherwise the rendered --- fields as-is. -bodyOrPass :: Text -> Text -bodyOrPass body - | T.null body = " pass\n" - | otherwise = body +typedDictCode :: Text -> [(Text, Text, Text)] -> Text +typedDictCode name fields + | any (\(field, _, _) -> field `elem` pyKeywords) fields = + (name <> " = TypedDict(\"" <> name <> "\", {\n") + <> foldMap (\(field, t, comment) -> " \"" <> field <> "\": " <> t <> "," <> comment <> "\n") fields + <> "})\n" + | null fields = "class " <> name <> "(TypedDict):\n pass\n" + | otherwise = + ("class " <> name <> "(TypedDict):\n") + <> foldMap (\(field, t, comment) -> " " <> field <> ": " <> t <> comment <> "\n") fields --- | Render record fields for a TypedDict body. Each field becomes --- `: [ # ]`. Optional fields wrap the type in --- `NotRequired[...]`. -fieldsCodePy :: Text -> Text -> [APIRecordField] -> Text -fieldsCodePy indent namespace = foldMap render +pyKeywords :: [Text] +pyKeywords = + ["False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield"] + +-- | Render record fields for a TypedDict as name, type and comment. +-- Optional fields wrap the type in `NotRequired[...]`. +fieldsCodePy :: Text -> [APIRecordField] -> [(Text, Text, Text)] +fieldsCodePy namespace = map render where - render (APIRecordField name t) = - indent <> T.pack name <> ": " <> wrapOptional t (typeText t) <> typeComment t <> "\n" + render (APIRecordField name t) = (T.pack name, wrapOptional t (typeText t), typeComment t) wrapOptional t inner = case t of ATOptional _ -> "NotRequired[" <> inner <> "]" _ -> inner diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 18392ae1b5..132e7adf38 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -92,6 +92,7 @@ chatResponsesDocsData = ("CRRemoteCtrlConnecting", "Remote controller is connecting."), ("CRSentConfirmation", "Confirmation sent to one-time invitation"), ("CRSentGroupInvitation", "Group invitation sent"), + ("CRSentGroupInvitationViaGroup", "Group invitation sent to member of another group"), ("CRSentInvitation", "Invitation sent to contact address"), ("CRSentInvitationToContact", "Invitation sent to contact (when connecting via SimpleX name to a known contact address)."), ("CRServiceReplyAccepted", "Service reply accepted for delivery. `connectionId` correlates the reply delivery event."), @@ -134,6 +135,7 @@ undocumentedResponses = "CRAppSettings", "CRArchiveExported", "CRArchiveImported", + "CRBadgeInvoice", "CRBadgeLedger", "CRBadgeRedeemed", "CRBadgeState", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index ac5992770e..ab76cc6581 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -224,6 +224,7 @@ chatTypesDocsData = (sti @BadgeServiceErrorCode, STEnum' (consSep "BSE" '_'), "", ["BSEUnknown"], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), (sti @BlockingReason, STEnum, "BR", [], "", ""), + (sti @BlockToken, STUnion, "", [], "", ""), (sti @BrokerErrorType, STUnion, "", [], "", ""), (sti @BusinessChatInfo, STRecord, "", [], "", ""), (sti @BusinessChatType, STEnum, "BC", [], "", ""), @@ -285,7 +286,7 @@ chatTypesDocsData = (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), (sti @FileType, STEnum' (consLower "FT"), "", [], "", ""), - (sti @Format, STUnion, "", ["Unknown"], "", ""), + (sti @Format, STUnion, "", ["Unknown"], "", "The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars."), (sti @FormattedText, STRecord, "", [], "", ""), (sti @FullGroupPreferences, STRecord, "", [], "", ""), (sti @FullPreferences, STRecord, "", [], "", ""), @@ -298,7 +299,7 @@ chatTypesDocsData = (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkOwner, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), - (sti @GroupMember, STRecord, "", [], "", ""), + (removeField "memberBadgeProof" $ sti @GroupMember, STRecord, "", [], "", ""), (sti @GroupMemberAdmission, STRecord, "", [], "", ""), (sti @GroupMemberCategory, STEnum' (dropPfxSfx "GC" "Member"), "", [], "", ""), (sti @GroupMemberRef, STRecord, "", [], "", ""), @@ -321,8 +322,10 @@ chatTypesDocsData = (sti @LinkContent, STUnion, "LC", [], "", ""), (sti @LinkOwnerSig, STRecord, "", [], "", ""), (sti @LinkPreview, STRecord, "", [], "", ""), + (sti @ListEntry, STRecord, "", [], "", ""), (sti @BadgeInfo, STRecord, "", [], "", ""), (sti @LocalProfile, STRecord, "", [], "", ""), + (sti @MarkdownBlock, STUnion, "", ["UnknownBlock"], "", ""), (sti @MemberCriteria, STEnum1, "MC", [], "", ""), (sti @MsgChatLink, STUnion, "MCL", [], "", "Connection link sent in a message - only short links are allowed."), (sti @MsgContent, STUnion, "MC", [], "", ""), @@ -371,6 +374,7 @@ chatTypesDocsData = (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), + (sti @SegmentRange, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), (sti @SimplexDomain, STRecord, "", [], "", ""), (sti @SimplexDomainClaim, STRecord, "", [], "", ""), @@ -391,6 +395,7 @@ chatTypesDocsData = (sti @SubscriptionStatus, STUnion, "SS", [], "", ""), (sti @SupportGroupPreference, STRecord, "", [], "", ""), (sti @SwitchPhase, STEnum, "SP", [], "", ""), + (sti @TableAlign, STEnum, "TA", [], "", ""), (sti @TimedMessagesGroupPreference, STRecord, "", [], "", ""), (sti @TimedMessagesPreference, STRecord, "", [], "", ""), (sti @TransportError, STUnion, "TE", [], "", ""), @@ -457,6 +462,7 @@ deriving instance Generic BadgeRedeemError deriving instance Generic BadgeServiceErrorCode deriving instance Generic BlockingInfo deriving instance Generic BlockingReason +deriving instance Generic BlockToken deriving instance Generic BrokerErrorType deriving instance Generic BusinessChatInfo deriving instance Generic BusinessChatType @@ -561,8 +567,10 @@ deriving instance Generic JSONCIStatus deriving instance Generic LinkContent deriving instance Generic LinkOwnerSig deriving instance Generic LinkPreview +deriving instance Generic ListEntry deriving instance Generic BadgeInfo deriving instance Generic LocalProfile +deriving instance Generic MarkdownBlock deriving instance Generic MemberCriteria deriving instance Generic MsgChatLink deriving instance Generic MsgContent @@ -610,6 +618,7 @@ deriving instance Generic RemoteCtrlSessionState deriving instance Generic RemoteCtrlStopReason deriving instance Generic ReportReason deriving instance Generic SecurityCode +deriving instance Generic SegmentRange deriving instance Generic SimplexDomain deriving instance Generic SimplexDomainClaim deriving instance Generic SimplexDomainError @@ -629,6 +638,7 @@ deriving instance Generic StoreError deriving instance Generic SubscriptionStatus deriving instance Generic SupportGroupPreference deriving instance Generic SwitchPhase +deriving instance Generic TableAlign deriving instance Generic TimedMessagesGroupPreference deriving instance Generic TimedMessagesPreference deriving instance Generic TransportError diff --git a/cabal.project b/cabal.project index 897de4e90b..d8868c31c1 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 38fdc0c544ac950c0662ebb16f278a31805fb81a + tag: aabf4cdab98f9fb4c92dc6d9ec661a5f08f0e80f source-repository-package type: git @@ -31,7 +31,7 @@ source-repository-package source-repository-package type: git location: https://github.com/simplex-chat/direct-sqlcipher.git - tag: f814ee68b16a9447fbb467ccc8f29bdd3546bfd9 + tag: 2330df3dc8c4b660674d3c02d76bb86f66c2abee source-repository-package type: git diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 3ecfa17409..7dbf4510e9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -57,11 +57,7 @@ You will have to add `/opt/homebrew/opt/openssl@3.0/bin` to your PATH in order t - `stable` - stable release of the apps, can be used for updates to the previous stable release (GHC 9.6.3). -- `stable-android` - used to build stable Android core library with Nix (GHC 8.10.7) - only for Android armv7a. - -- `master` - branch for beta version releases (compatible with both GHC 9.6.3 and 8.10.7). - -- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7) - only for Android armv7a. +- `master` - branch for beta version releases (compatible with both GHC 9.6.3 and 8.10.7). Nix builds Android armv7a core library with GHC 8.10.7 and all other core libraries with GHC 9.6.3. **In simplexmq repo** @@ -71,18 +67,13 @@ You will have to add `/opt/homebrew/opt/openssl@3.0/bin` to your PATH in order t 1. Make PRs to `master` branch _only_ for both simplex-chat and simplexmq repos. -2. To build core libraries for Android, iOS and Windows: -- merge `master` branch to `master-android` branch. -- push to GitHub. +2. To build core libraries for Android, iOS and Windows, push `master` branch to GitHub. -3. All libraries should be built from `master` branch, Android armv7a - from `master-android` branch. +3. All libraries should be built from `master` branch. 4. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. -5. After the public release to App Store and Play Store, merge: -- `master` to `stable` -- `master` to `master-android` (and compile/update code) -- `master-android` to `stable-android` +5. After the public release to App Store and Play Store, merge `master` to `stable`. 6. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 95cf972c0d..92c0dac545 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -1,11 +1,12 @@ --- title: Download SimpleX apps -permalink: /downloads/index.html revision: 09.09.2024 --- # Download SimpleX apps +Download the app for your platform: https://simplex.chat/downloads/ + You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). If you cannot access GitHub, you can download SimpleX Chat apps from our mirror at [git.simplex.chat](https://git.simplex.chat/simplex-chat/simplex-chat/releases) diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md index edf3ba199b..44e99a42a9 100644 --- a/docs/JOIN_TEAM.md +++ b/docs/JOIN_TEAM.md @@ -49,6 +49,6 @@ This is a part-time remote contract. You must be in UTC +/- 8 hours timezone. ## How to join the team -To apply, please [install SimpleX Chat app](./DOWNLOADS.md) and send your achievements to this [SimpleX address](https://smp16.simplex.im/a#OGL3qf7utOrUERFoFOROgdQaAkj_znzoeACNKDAsFNA). +To apply, please [install SimpleX Chat app](https://simplex.chat/downloads/) and send your achievements to this [SimpleX address](https://smp16.simplex.im/a#OGL3qf7utOrUERFoFOROgdQaAkj_znzoeACNKDAsFNA). **We do NOT review CVs at the initial stage, please only send the links to your achievements**. diff --git a/docs/WHY.md b/docs/WHY.md index e1582984df..b36df4a154 100644 --- a/docs/WHY.md +++ b/docs/WHY.md @@ -17,3 +17,5 @@ The oldest human freedom — to speak to another person without being watched Because we destroyed the power to know who you are. So that your power can never be taken. Be free in your network. + +[Watch the film](https://www.youtube.com/watch?v=k-GhLNZm8G0) diff --git a/docs/contributing/RELEASES.md b/docs/contributing/RELEASES.md new file mode 100644 index 0000000000..e0b80fda5b --- /dev/null +++ b/docs/contributing/RELEASES.md @@ -0,0 +1,154 @@ +# Library and package releases + +## Releases + +| Release | Location | Version | Published on | +|---|---|---|---| +| libsimplex | simplex-chat-libs release `core-` | `version` of `simplex-chat.cabal` | push to master or stable with a new core version | +| Node.js add-on | simplex-chat-libs release `nodejs-addon-<7 characters of the commit>` | | push to master or stable with unreleased add-on sources | +| `@simplex-chat/types` | npm | `version` of `packages/simplex-chat-client/types/typescript/package.json` | library release with an unpublished types version | +| `simplex-chat` | npm | `version` of `packages/simplex-chat-nodejs/package.json` | push to master or stable with an unpublished version | +| `simplex-chat` | PyPI | `__version__` of `packages/simplex-chat-python/src/simplex_chat/_version.py` | push to master or stable with an unpublished version | + +Add-on sources: +- `packages/simplex-chat-nodejs/binding.gyp` +- `packages/simplex-chat-nodejs/cpp/` +- `node-addon-api` version in `devDependencies` of `packages/simplex-chat-nodejs/package.json` + +Release states in simplex-chat-libs: + +``` +draft created by maybe-release; assets uploaded by the build jobs +published every asset present; published by the releases job +``` + +npm tag: `beta` for a version with a prerelease part, `latest` otherwise. + +Releases and publications are independent of the Haskell test results. + +`releases.toml` of simplex-chat-libs, written by `build.yml`: + +``` +core.. commit of the library release; branch: master or stable +npm.. commit of the publication +pypi.. commit of the publication +``` + +## Library release + +1. On master or stable, after `git pull`: + + ``` + python3 scripts/ci/packages_release.py core-release + ``` + + Checked: version higher than the highest version recorded for the branch. + + Written: + - `version` of `simplex-chat.cabal` + - with types changed since the previous library release of the branch: types `version` (bumped), `simplexChatCore`, line `SimpleX Chat core:` of the types README + + `--types-version` sets the types version, for the first release of an app version, e.g. `--types-version 7.1.0-beta.7.1`. + +2. Commit, push to the same branch. + +3. Run of `build.yml`: + - `maybe-release`: version claimed for the commit in `releases.toml`, draft `core-` created + - build jobs: 6 library zips uploaded; macOS libraries signed with the Developer ID and notarized + - `releases`: release published + - `publish`: `@simplex-chat/types` published, when its version is unpublished + - `record`: types version recorded + +Failed claim, with every build job skipped: +- version lower than the highest version recorded for the branch +- types changed, types version published +- types changed, `simplexChatCore` different from the core version + +After a failed claim, a fix commit is pushed; the version is claimed by the next push. + +Incomplete draft: completed by a re-run of the failed jobs in the run of the claim. The version is skipped by later pushes, as it is recorded for another commit. + +## Add-on release + +Run of `build.yml` on a push to master or stable with unreleased add-on sources: +- `maybe-release`: draft `nodejs-addon-<7 characters of the commit>` created, notes `sources: ` +- build jobs, 5 platforms: add-on built, tested with the latest library release of the branch, uploaded +- `releases`: release published + +Incomplete draft: completed by a re-run or by the next push with the same add-on sources. + +## Package release + +1. On master or stable, after `git pull`: + + ``` + python3 scripts/ci/packages_release.py prepare [nodejs] [python] + ``` + + Both packages when none is listed. + + Checked: + - published library release recorded for the branch + - add-on release published with every asset (Node.js) + - types version published on npm (Node.js) + - Python types unchanged since the commit of the pinned library release (Python) + - pinned releases published with the pinned SHA-256 + - every pinned asset downloaded with a matching SHA-256 + + Written: + - pins in `packages/simplex-chat-nodejs/src/release.ts` and `_version.py`: latest library release of the branch, add-on release of the current sources (Node.js), SHA-256 of every asset + - `@simplex-chat/types` dependency: `^` (Node.js) + - package versions: a published version bumped when the pins or the package files changed + + `--nodejs-version` and `--python-version` set the versions, for the first release of an app version, e.g. `--nodejs-version 7.1.0-beta.7.1 --python-version 7.1.0b7`. + +2. Commit, push to the same branch. + +3. Run of `build.yml`: + - build jobs, 5 platforms: packages tested, Node.js with the pinned add-on + - `publish`: package tests of 5 platforms and pins checked, unpublished versions published with trusted publishing + - `record`: published versions recorded + +Versions unpublished after a failure: published by the next push to the branch. + +## Local publication + +1. Step 1 of the package release. + +2. Package tests in a Python virtual environment, on the local platform (`linux-x86_64`, `linux-aarch64`, `macos-aarch64`, `macos-x86_64` or `windows-x86_64`): + + ``` + scripts/ci/package-tests.sh publish nodejs python + ``` + +3. In `packages/simplex-chat-nodejs`, with the npm tag of the version: + + ``` + npm publish --access public --tag beta + ``` + +4. In `packages/simplex-chat-python`: + + ``` + python -m pip install build twine + python -m build + twine upload dist/simplex_chat-* + ``` + +5. Commit, push to the same branch. Locally published versions are skipped by `build.yml` and are not recorded in `releases.toml`. + +## Settings + +- simplex-chat-libs: release immutability enabled +- secret `NODEJS_REPO_TOKEN`: contents of simplex-chat-libs, read and write +- secrets `APPLE_SIMPLEX_SIGNING_KEYCHAIN`, `APPLE_SIMPLEX_NOTARIZATION_APPLE_ID` and `APPLE_SIMPLEX_NOTARIZATION_PASSWORD`: signing and notarization of the macOS libraries, shared with the desktop app build +- trusted publisher of `simplex-chat` and `@simplex-chat/types` on npm and of `simplex-chat` on PyPI: workflow `build.yml` +- secrets `NPM_TOKEN` and `PYPI_API_TOKEN`: used instead of trusted publishing when set +- variables `NPM_SCOPE`, `PYPI_PROJECT`, `PYPI_URL` and `PYPI_UPLOAD_URL`: publication under other names, for test repositories + +## Pull requests + +- `packages.yml`, for package changes: + - core unchanged: add-on built and packages tested on 5 platforms + - core changed: add-on built on 5 platforms when its sources changed +- `build.yml`, for core changes: package tests on Linux x86_64 with the library built from the pull request diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 8c43ca6b28..249baa5431 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -20,21 +20,43 @@ A timeout hides the outcome, so the client repeats the identical signed request - `getBadgeInvoice` — returns the open invoice again; a new invoice is created only when none is open. - `redeemBadgeCode` — a code already redeemed by the signing key returns its latest `badgeCredential` and writes nothing; once other keys have taken all its uses, `code_used`. The client must therefore keep the key it first signed with, or a retry cannot be recognised. -- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing. +- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing; a store payment credited to another key, `receipt_used`. - `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. - `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. -- `purchaseBadge` with a `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. +- `purchaseBadge` with the recovery `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. ## Commands -`purchaseBadge` and `upgradeBadgeSubscription` carry `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3) and is required — a credential always expires, and a badge that does not is expressed as a long finite term; `badgeExtra` is reserved and must be empty. `issueBadge` carries no `badgeRequest`: the tier, the master key and the expiry are all the service's own, so there is nothing for the client to state. +The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the `sundayAfter` of the period issued (`endOfMondayAfter` in code), shared by every credential of that week; `badgeExtra` is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries `badgeRequest`. `upgradeBadgeSubscription` still has the field in the schema; that is wrong and it must be dropped when the command is implemented. - `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config. - `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. - `redeemBadgeCode` → `badgeCredential` — redeems a code, records the credit, and issues the first credential, in one round trip. It carries `masterKey` and `code` and no `badgeRequest`: a code states no tier and no expiry, so the credential is what reports them. Errors: `code_invalid` for an unknown or malformed code, `code_used` when other keys have taken all its uses (a code has one use unless issued with more), `code_expired` past a redemption deadline. -- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. +- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` product id and token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. It carries `masterKey` and no `badgeRequest`. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. + - A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. A receipt already credited to the signing key is answered from the record, without asking the store. + - Errors: `receipt_invalid`, `test_purchase`, `payment_pending`, `provider_unavailable` — see the classes below; `receipt_used` when another key was credited with it; `product_unavailable` for a product this service does not price; `provider_not_configured` when this deployment has no verifier for the store. `receipt_invalid`, `test_purchase` and `receipt_used` are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger. + - **Which class an answer belongs to.** The two mistakes do not cost the same, so when it is not certain, answer unreachable. `receipt_invalid` tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good. + - **Terminal**, `receipt_invalid` — only a verdict no later attempt could change: the store answered about this purchase and cannot change its mind, or the evidence can never verify. Anything that might answer differently later — the store's lag, an outage, this service's own misconfiguration or bug, a shape of evidence this service does not know yet — is one of the other classes. + - **Terminal and named**, `test_purchase` — the store vouched for a purchase that cost nothing: Apple's `Sandbox`, or Play's `purchaseType` test. It is separated from `receipt_invalid` for one reason: the device that made the purchase already knows it was a test one, so naming it reveals nothing a forger could iterate on, and it lets the app say why instead of reporting a generic failure. Every other refusal stays lumped together for exactly that reason. + - **Pending**, `payment_pending` — a real purchase the store has not settled. + - **Unreachable**, `provider_unavailable` — the store was not asked or did not answer. + - **Internal**, `internal` — this service's own failure, never the store's answer. + - **App Store.** Apple is verified offline, so it has no "not yet". + - `receipt_invalid`: a JWS that is not three base64url parts or names no `transactionId`; an `x5c` certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a `revocationDate`, refunded or revoked. + - `test_purchase`: refused by the service after the verifier vouched for it, a `Sandbox` transaction. + - `internal`: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's `bundleId`, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name `ES256` or carries no `x5c` of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown `environment`; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1. + - `product_unavailable`: a product this service does not price — the verifier vouched for the transaction either way. + - `provider_not_configured`: the deployment has no `[apple]` section. + - **Google Play.** Only a purchase record is a verdict. + - `receipt_invalid`: `purchaseState` canceled, which a purchase token never leaves, and a product id outside the characters Play documents, since no product of this app can have one. + - `test_purchase`: refused by the service after the verifier vouched for it, `purchaseType` test, a license tester's purchase. + - `payment_pending`: `purchaseState` pending. + - `provider_unavailable`: network failure or timeout, and every error response from `purchases.products.get` but 401 and 403 — a 404 included, which Play may answer both for a token it has just issued and not yet recorded and for one that was never a purchase, and a 400 or 410 calling the token invalid; a 5xx or 429 from the token endpoint; and a token this service will not send — whose characters fall outside the grammar it accepts, which is our guess since Play documents none, or which is only dots, which a URL path would resolve away. + - `internal`: Google refusing this service's credentials or permissions — a 401 or 403 from `purchases.products.get`, and any other refusal from the token endpoint; a response that cannot be read, is too large, or names another product; a `purchaseState` this service does not know; and a `purchaseType` other than test or promo — a promo code, which only this app's developer issues, is credited like a purchase. + - `product_unavailable` and `provider_not_configured` as for the App Store, the latter without a `[google]` section. + - Before crediting a purchase of a product it prices, the service acknowledges it, unless Play already reports it acknowledged or consumed. It acknowledges and never consumes, so the app's purchase query still lists the purchase. The app may not be honest, and only acknowledgement stops Play refunding the purchase after three days, so nothing is credited unacknowledged. A failed acknowledgement credits nothing and is answered like a failed read — `provider_unavailable` when Play does not answer, `internal` when it refuses this service, both logged with "acknowledging" in the reason — and the app presents the purchase again. A deployment whose service account lacks the permission to acknowledge therefore answers `internal` to every Play purchase the app has not already acknowledged itself. - Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them. -- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. +- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. Its `badgeRequest` is to be dropped (see above). - `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. It carries `balance` alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the `sundayAfter` of the period issued. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). - `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). @@ -63,4 +85,4 @@ An assertion that names an entry the service holds is a prefix: the service proc ## Errors -`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers unknown receipts. All other codes are terminal for the attempted command. +`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; `test_purchase` is named apart from them because the device that bought it already knows; `receipt_used` — credited to another key; `provider_not_configured` — no verifier for that store is deployed, so the receipt is neither credited nor refused. All other codes are terminal for the attempted command. diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index 01581a5bac..f9ca9d4a14 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -95,7 +95,7 @@ "properties": {"jws": {"type": "string"}} }, "google": { - "properties": {"token": {"type": "string"}} + "properties": {"productId": {"type": "string"}, "token": {"type": "string"}} }, "invoice": { "properties": {"invoiceId": {"type": "string"}} @@ -309,7 +309,7 @@ }, "purchaseBadge": { "properties": { - "badgeRequest": {"ref": "badgeRequest"}, + "masterKey": {"ref": "base64url"}, "payment": {"ref": "payment"} }, "optionalProperties": { @@ -321,7 +321,10 @@ }, "upgradeBadgeSubscription": { "properties": { - "badgeRequest": {"ref": "badgeRequest"}, + "badgeRequest": { + "ref": "badgeRequest", + "metadata": {"comment": "known wrong: to be dropped when implemented, see badges-rpc.md"} + }, "payment": { "ref": "payment", "metadata": {"comment": "store payments only"} diff --git a/docs/protocol/channels-protocol.md b/docs/protocol/channels-protocol.md index 6a232ea2ff..fe34bf3576 100644 --- a/docs/protocol/channels-protocol.md +++ b/docs/protocol/channels-protocol.md @@ -44,11 +44,11 @@ Creating a channel involves generating cryptographic material, creating the chan When a relay receives an invitation to serve a channel, it validates the channel and creates its own relay link. This flow is currently part of channel creation; adding relays to an existing channel is planned but not yet implemented. -1. Owner sends `x.grp.relay.inv` to the relay's contact address. This message includes the relay's member ID and role, the owner's profile, and the channel's short link. +1. Owner sends `x.grp.relay.inv` to the relay's contact address. This message includes the relay's member ID and role, the owner's profile, the channel's short link, the channel's public group ID, and the owner's member key. The message is signed with the owner's member key over the public group ID and the owner's member ID. -2. Relay receives the invitation and creates a relay request record. A relay request worker processes it asynchronously. +2. Relay receives the invitation and creates a relay request record. From chat protocol version 22 of the owner, an invitation without a valid signature is ignored. The owner's badge is verified under the public group ID, the owner's member ID and the owner's member key from a signed invitation. A relay request worker processes it asynchronously. -3. The worker retrieves the channel's link data from the SMP server, extracts and validates the channel profile and owner authorization. +3. The worker retrieves the channel's link data from the SMP server, extracts and validates the channel profile and owner authorization. The request fails when the link's entity ID differs from the public group ID in the invitation, or when the owner's key in the link data differs from the owner's member key in the invitation. 4. The relay creates its own contact address link (the relay link) with the channel's entity ID in the immutable fixed data. diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index 13e1464881..a95e7ee75f 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -232,7 +232,9 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.inv` message is sent to invite contact to the group via contact's direct connection and includes group member connection address. This message MUST only be sent by members with `admin` or `owner` role. Optional `groupLinkId` is included when this message is sent to contacts connected via the user's group link. This identifier is a random byte sequence, with no global or even local uniqueness - it is only used for the user's invitations to a given group to provide confirmation to the contact that the group invitation is for the same group the contact was connecting to via the group link, so that the invitation can be automatically accepted by the contact - the contact compares it with the group link id contained in the group link uri's data field. -`x.grp.acpt` message is sent as part of group member connection handshake, only to the inviting user. +`x.grp.inv` message can also be sent to a group member via the member connection, to invite the member to another group. In this case, the message MUST only be sent when direct messages are allowed for the sender in the group of the member connection, and receiving clients MUST ignore it otherwise. + +`x.grp.acpt` message is sent as part of group member connection handshake, only to the inviting user. It optionally includes the invited member's group profile. The inviting user replies with `x.grp.mem.info` that includes the inviting member's group profile. `x.grp.link.inv` message is sent as part of connection handshake to member joining via group link, and contains group profile and initial information about inviting and joining member. @@ -284,7 +286,15 @@ These message are used for WebRTC calls: 3. `x.call.answer`: to continue with call connection the initiating clients must reply with `x.call.answer` message. This message contains WebRTC answer and collected ICE candidates. Additional ICE candidates can be sent in `x.call.extra` message. -4. `x.call.end` message is sent to notify the other party that the call is terminated. +4. `x.call.extra` with `rtcSession` property: when an established connection is lost, the client that sent `x.call.offer` restarts ICE and sends the new offer in `rtcSession` of `x.call.extra`. The other client replies with the answer in `rtcSession` of `x.call.extra`. Clients that do not support it ignore this property, and the call ends when reconnection times out. + +5. `x.call.end` message is sent to notify the other party that the call is terminated. + +Call messages have their own version, independent of the chat protocol version. The initiating client sends the range of call versions it supports in `callVRange` property of `x.call.inv`. The receiving client chooses the highest version supported by both clients and sends it in `callVersion` property of `x.call.offer`. The initiating client rejects the offer if this version is not in the range it sent. A missing range or version means version 1. + +Media frames are encrypted with AES-256-GCM using the key agreed via X25519 DH keys in `x.call.inv` and `x.call.offer`: +- version 1: the key is the DH shared secret. +- version 2: the key is 32 bytes of HKDF-SHA512 output, using the DH shared secret as input key material, call ID as salt and `SimpleXCallMediaKey` as info. ## Threat model diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index 4614ba6929..a9bb2d8735 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -25,7 +25,7 @@ "metadata": {"comment": "profile bio (160 characters)"} }, "contactLink": {"ref": "connReqUri"}, - "peerType": {"enum": ["human", "bot"]}, + "peerType": {"enum": ["human", "bot", "agent"]}, "preferences": { "optionalProperties": { "commands": { @@ -325,9 +325,6 @@ "introInvitation": { "properties": { "groupConnReq": {"ref": "connReqUri"} - }, - "optionalProperties": { - "directConnReq": {"ref": "connReqUri"} } }, "groupMemberRole": { @@ -338,7 +335,13 @@ "callType": {"ref": "callType"} }, "optionalProperties": { - "callDhPubKey": {"ref": "base64url"} + "callDhPubKey": {"ref": "base64url"}, + "callVRange": { + "type": "string", + "metadata": { + "format": "call version range, e.g. \"1-2\", or a single version \"1\"" + } + } } }, "callOffer": { @@ -347,7 +350,8 @@ "rtcSession": {"ref": "webRTCSession"} }, "optionalProperties": { - "callDhPubKey": {"ref": "base64url"} + "callDhPubKey": {"ref": "base64url"}, + "callVersion": {"type": "uint16"} } }, "callAnswer": { @@ -360,6 +364,9 @@ "rtcExtraInfo": { "properties": { "rtcIceCandidates": {"type": "string"} + }, + "optionalProperties": { + "rtcSession": {"type": "string"} } } } @@ -564,6 +571,9 @@ "params": { "properties": { "memberId": {"ref": "base64url"} + }, + "optionalProperties": { + "profile": {"ref": "profile"} } } } diff --git a/docs/rfcs/2026-10-10-invite-member-via-group.md b/docs/rfcs/2026-10-10-invite-member-via-group.md new file mode 100644 index 0000000000..865c8b08bc --- /dev/null +++ b/docs/rfcs/2026-10-10-invite-member-via-group.md @@ -0,0 +1,204 @@ +# Inviting group members to other groups + +## Problem + +A group admin can invite only a contact to a group. +To invite a member of group A to group B, the admin first creates a contact with this member: + +1. Create a member contact (`APICreateMemberContact`). +2. Send the contact invitation in group A (`x.grp.direct.inv`). +3. Wait until the contact connection is established. +4. Invite the contact to group B (`APIAddMember`, `x.grp.inv`). + +Steps 1-3 are possible only when direct messages are allowed in group A. +A direct chat is created with each invited member. + +## Design + +An admin of groups A and B invites a member of group A to group B. +The invitation is sent as `x.grp.inv` over the connection with this member in group A. +Only the invited member receives the invitation. +The invited member sees it in group A and joins group B in the same way as after an invitation from a contact. + +### Protocol + +`x.grp.inv` is accepted on group member connections, in addition to contact connections. + +``` +event x.grp.inv +params groupInvitation, unchanged +connection connection with the invited member in group A +forwarding excluded +group history excluded +``` + +Sender requirements: + +``` +group A preferences direct messages allowed for the user's role +role in group A author or above +role in group B admin or owner, and not lower than the invited role +group A group, not channel, different from group B +group B group, not channel +membership in A main profile +membership in B main profile +invited member current member with an active connection +invited member version 22 or later +``` + +The receiving client ignores the invitation when: + +``` +group A channel +group B channel +group A preferences direct messages not allowed for the sender's role +sender blocked +fromMember role below admin, or below the invited role +fromMember id equal to the invited member id +``` + +### Sending + +New command, next to `APIAddMember`: + +```haskell +APIAddMemberViaGroup + { groupId :: GroupId, + viaGroupId :: GroupId, + groupMemberId :: GroupMemberId, + memberRole :: GroupMemberRole + } +``` + +``` +groupId group B +viaGroupId group A +groupMemberId member of group A +memberRole role in group B +``` + +Syntax: `/_add # # ` + +For a member outside group A, `SEGroupMemberNotFound` is returned. + +New response, next to `CRSentGroupInvitation`: + +```haskell +CRSentGroupInvitationViaGroup + { user :: User, + groupInfo :: GroupInfo, + member :: GroupMember, + viaGroupInfo :: GroupInfo, + viaMember :: GroupMember + } +``` + +``` +groupInfo group B +member record of the invited member in group B +viaGroupInfo group A +viaMember member of group A +``` + +In group B, the record of the invited member is linked to the member of group A. +Until the member joins, the same invitation is re-sent on a repeated command. +After the member joins, `CEGroupDuplicateMember` is returned. +After the member leaves, is removed or is rejected, a new invitation is sent. + +The sent invitation is saved in group A as `CISndGroupInvitation`, visible to the sender only. +The name of the member in group A is stored in the item (`CIGroupInvitation.localDisplayName`), as the contact name in direct chats. + +### Receiving + +New event, next to `CEvtReceivedGroupInvitation`: + +```haskell +CEvtReceivedGroupInvitationViaGroup + { user :: User, + groupInfo :: GroupInfo, + viaGroupInfo :: GroupInfo, + viaMember :: GroupMember, + fromMemberRole :: GroupMemberRole, + memberRole :: GroupMemberRole + } +``` + +``` +groupInfo group B +viaGroupInfo group A +viaMember sender, member of group A +fromMemberRole sender role in group B +memberRole invited role in group B +``` + +``` +group B created with status "invited", or found by the invitation link +chat item CIRcvGroupInvitation in group A, main scope, from the sender +``` + +With automatic acceptance of group invitations enabled, the client joins group B and saves the item with status "accepted". + +With an incognito membership in group A, the same incognito profile is used in group B. + +In group B, copies of the group A profiles are stored for the sender and for the invited member. +Badge proofs bound to group A are dropped from the copies. +The copies are replaced by the profiles sent in the connection handshake (`x.grp.acpt`, `x.grp.mem.info`). + +### Joining + +The invited member joins group B with `APIJoinGroup`, or by tapping the invitation in group A. + +Status of the invitation item: + +``` +pending received +accepted joined +rejected group B deleted before joining +``` + +With disappearing messages on in group A, the item disappears after the status changes, as in direct chats. + +The connection with the host is created from the invitation link, independently of the inviter's connection. +So an invitation from a contact deleted before joining is accepted in the same way. +Before this change, joining failed with `SEContactNotFoundByMemberId`. + +### Clients + +``` +group A existing group invitation card ("Tap to join"), with a label +chat list group B, added on CEvtReceivedGroupInvitationViaGroup +sending API only +``` + +Labels, on group invitation cards in group chats only: + +``` +received card For you only +sent card For only name of the member in group A +``` + +### Bots API + +``` +APIAddMemberViaGroup new command +CRSentGroupInvitationViaGroup new response +CEvtReceivedGroupInvitationViaGroup new event +``` + +`APIAddMember`, `CRSentGroupInvitation` and `CEvtReceivedGroupInvitation`: unchanged. + +### Version + +``` +chat version 22 +``` + +Version 22 is shared with owner-signed relay invitations, in one constant `groupInvVersion`. +Version 22 is not in any release: v7.1.0-beta.6 uses version 21. + +## Out of scope + +- Role changes of members invited via a group before they join. +- "Open" action on accepted invitations. +- Invitations to members pending approval in group A. +- User interface for sending invitations. diff --git a/flake.lock b/flake.lock index eac7357cd8..2b1d187945 100644 --- a/flake.lock +++ b/flake.lock @@ -16,6 +16,37 @@ "type": "github" } }, + "HTTP_2": { + "flake": false, + "locked": { + "lastModified": 1451647621, + "narHash": "sha256-oHIyw3x0iKBexEo49YeUDV1k74ZtyYKGR2gNJXXRxts=", + "owner": "phadej", + "repo": "HTTP", + "rev": "9bc0996d412fef1787449d841277ef663ad9a915", + "type": "github" + }, + "original": { + "owner": "phadej", + "repo": "HTTP", + "type": "github" + } + }, + "blank": { + "locked": { + "lastModified": 1625557891, + "narHash": "sha256-O8/MWsPBGhhyPoPLHZAuoZiiHo9q6FLlEeIDEXuj6T4=", + "owner": "divnix", + "repo": "blank", + "rev": "5a5d2684073d9f563072ed07c871d577a6c614a8", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "blank", + "type": "github" + } + }, "cabal-32": { "flake": false, "locked": { @@ -33,6 +64,23 @@ "type": "github" } }, + "cabal-32_2": { + "flake": false, + "locked": { + "lastModified": 1603716527, + "narHash": "sha256-X0TFfdD4KZpwl0Zr6x+PLxUt/VyKQfX7ylXHdmZIL+w=", + "owner": "haskell", + "repo": "cabal", + "rev": "48bf10787e27364730dd37a42b603cee8d6af7ee", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.2", + "repo": "cabal", + "type": "github" + } + }, "cabal-34": { "flake": false, "locked": { @@ -50,6 +98,23 @@ "type": "github" } }, + "cabal-34_2": { + "flake": false, + "locked": { + "lastModified": 1645834128, + "narHash": "sha256-wG3d+dOt14z8+ydz4SL7pwGfe7SiimxcD/LOuPCV6xM=", + "owner": "haskell", + "repo": "cabal", + "rev": "5ff598c67f53f7c4f48e31d722ba37172230c462", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.4", + "repo": "cabal", + "type": "github" + } + }, "cabal-36": { "flake": false, "locked": { @@ -67,6 +132,23 @@ "type": "github" } }, + "cabal-36_2": { + "flake": false, + "locked": { + "lastModified": 1669081697, + "narHash": "sha256-I5or+V7LZvMxfbYgZATU4awzkicBwwok4mVoje+sGmU=", + "owner": "haskell", + "repo": "cabal", + "rev": "8fd619e33d34924a94e691c5fea2c42f0fc7f144", + "type": "github" + }, + "original": { + "owner": "haskell", + "ref": "3.6", + "repo": "cabal", + "type": "github" + } + }, "cardano-shell": { "flake": false, "locked": { @@ -83,6 +165,80 @@ "type": "github" } }, + "cardano-shell_2": { + "flake": false, + "locked": { + "lastModified": 1608537748, + "narHash": "sha256-PulY1GfiMgKVnBci3ex4ptk2UNYMXqGjJOxcPy2KYT4=", + "owner": "input-output-hk", + "repo": "cardano-shell", + "rev": "9392c75087cb9a3d453998f4230930dea3a95725", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "cardano-shell", + "type": "github" + } + }, + "devshell": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1663445644, + "narHash": "sha256-+xVlcK60x7VY1vRJbNUEAHi17ZuoQxAIH4S4iUFUGBA=", + "owner": "numtide", + "repo": "devshell", + "rev": "e3dc3e21594fe07bdb24bdf1c8657acaa4cb8f66", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "dmerge": { + "inputs": { + "nixlib": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ], + "yants": [ + "haskellNix-ghc8107", + "tullia", + "std", + "yants" + ] + }, + "locked": { + "lastModified": 1659548052, + "narHash": "sha256-fzI2gp1skGA8mQo/FBFrUAtY0GQkAIAaV/V127TJPyY=", + "owner": "divnix", + "repo": "data-merge", + "rev": "d160d18ce7b1a45b88344aa3f13ed1163954b497", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "data-merge", + "type": "github" + } + }, "flake-compat": { "flake": false, "locked": { @@ -100,6 +256,39 @@ "type": "github" } }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1672831974, + "narHash": "sha256-z9k3MfslLjWQfnjBtEtJZdq3H7kyi2kQtUThfTgdRk0=", + "owner": "input-output-hk", + "repo": "flake-compat", + "rev": "45f2638735f8cdc40fe302742b79f248d23eb368", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "ref": "hkm/gitlab-fix", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-compat_3": { + "flake": false, + "locked": { + "lastModified": 1650374568, + "narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "b4a34015c698c7793d592d66adbab377907a2be8", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": "nixpkgs-lib" @@ -136,6 +325,51 @@ "type": "github" } }, + "flake-utils_2": { + "locked": { + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_3": { + "locked": { + "lastModified": 1653893745, + "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_4": { + "locked": { + "lastModified": 1659877975, + "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "ghc-8.6.5-iohk": { "flake": false, "locked": { @@ -153,6 +387,23 @@ "type": "github" } }, + "ghc-8.6.5-iohk_2": { + "flake": false, + "locked": { + "lastModified": 1600920045, + "narHash": "sha256-DO6kxJz248djebZLpSzTGD6s8WRpNI9BTwUeOf5RwY8=", + "owner": "input-output-hk", + "repo": "ghc", + "rev": "95713a6ecce4551240da7c96b6176f980af75cae", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "ref": "release/8.6.5-iohk", + "repo": "ghc", + "type": "github" + } + }, "ghc98X": { "flake": false, "locked": { @@ -190,6 +441,25 @@ "url": "https://gitlab.haskell.org/ghc/ghc" } }, + "gomod2nix": { + "inputs": { + "nixpkgs": "nixpkgs_3", + "utils": "utils" + }, + "locked": { + "lastModified": 1655245309, + "narHash": "sha256-d/YPoQ/vFn1+GTmSdvbSBSTOai61FONxB4+Lt6w/IVI=", + "owner": "tweag", + "repo": "gomod2nix", + "rev": "40d32f82fc60d66402eb0972e6e368aeab3faf58", + "type": "github" + }, + "original": { + "owner": "tweag", + "repo": "gomod2nix", + "type": "github" + } + }, "hackage": { "flake": false, "locked": { @@ -206,6 +476,23 @@ "type": "github" } }, + "hackage-ghc8107": { + "flake": false, + "locked": { + "lastModified": 1702340598, + "narHash": "sha256-CC0HI+6iKPtH+8r/ZfcpW5v/OYvL7zMwpr0xfkXV1zU=", + "owner": "input-output-hk", + "repo": "hackage.nix", + "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "hackage.nix", + "rev": "24617c569995e38bf3b83b48eec6628a50fdb4fb", + "type": "github" + } + }, "haskellNix": { "inputs": { "HTTP": "HTTP", @@ -260,6 +547,50 @@ "type": "github" } }, + "haskellNix-ghc8107": { + "inputs": { + "HTTP": "HTTP_2", + "cabal-32": "cabal-32_2", + "cabal-34": "cabal-34_2", + "cabal-36": "cabal-36_2", + "cardano-shell": "cardano-shell_2", + "flake-compat": "flake-compat_2", + "flake-utils": "flake-utils_2", + "ghc-8.6.5-iohk": "ghc-8.6.5-iohk_2", + "hackage": [ + "hackage-ghc8107" + ], + "hpc-coveralls": "hpc-coveralls_2", + "hydra": "hydra_2", + "iserv-proxy": "iserv-proxy_2", + "nixpkgs": [ + "nixpkgs-ghc8107" + ], + "nixpkgs-2003": "nixpkgs-2003_2", + "nixpkgs-2105": "nixpkgs-2105_2", + "nixpkgs-2111": "nixpkgs-2111_2", + "nixpkgs-2205": "nixpkgs-2205_2", + "nixpkgs-2211": "nixpkgs-2211_2", + "nixpkgs-unstable": "nixpkgs-unstable_2", + "old-ghc-nix": "old-ghc-nix_2", + "stackage": "stackage_2", + "tullia": "tullia" + }, + "locked": { + "lastModified": 1677975916, + "narHash": "sha256-dbe8lEEPyfzjdRwpePClv7J9p9lQg7BwbBqAMCw4RLw=", + "owner": "input-output-hk", + "repo": "haskell.nix", + "rev": "ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "haskell.nix", + "rev": "ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4", + "type": "github" + } + }, "hls-1.10": { "flake": false, "locked": { @@ -395,6 +726,22 @@ "type": "github" } }, + "hpc-coveralls_2": { + "flake": false, + "locked": { + "lastModified": 1607498076, + "narHash": "sha256-8uqsEtivphgZWYeUo5RDUhp6bO9j2vaaProQxHBltQk=", + "owner": "sevanspowell", + "repo": "hpc-coveralls", + "rev": "14df0f7d229f4cd2e79f8eabb1a740097fdfa430", + "type": "github" + }, + "original": { + "owner": "sevanspowell", + "repo": "hpc-coveralls", + "type": "github" + } + }, "hydra": { "inputs": { "nix": "nix", @@ -418,6 +765,52 @@ "type": "indirect" } }, + "hydra_2": { + "inputs": { + "nix": "nix_2", + "nixpkgs": [ + "haskellNix-ghc8107", + "hydra", + "nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1671755331, + "narHash": "sha256-hXsgJj0Cy0ZiCiYdW2OdBz5WmFyOMKuw4zyxKpgUKm4=", + "owner": "NixOS", + "repo": "hydra", + "rev": "f48f00ee6d5727ae3e488cbf9ce157460853fea8", + "type": "github" + }, + "original": { + "id": "hydra", + "type": "indirect" + } + }, + "incl": { + "inputs": { + "nixlib": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1669263024, + "narHash": "sha256-E/+23NKtxAqYG/0ydYgxlgarKnxmDbg6rCMWnOBqn9Q=", + "owner": "divnix", + "repo": "incl", + "rev": "ce7bebaee048e4cd7ebdb4cee7885e00c4e2abca", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "incl", + "type": "github" + } + }, "iserv-proxy": { "flake": false, "locked": { @@ -435,6 +828,23 @@ "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" } }, + "iserv-proxy_2": { + "flake": false, + "locked": { + "lastModified": 1670983692, + "narHash": "sha256-avLo34JnI9HNyOuauK5R69usJm+GfW3MlyGlYxZhTgY=", + "ref": "hkm/remote-iserv", + "rev": "50d0abb3317ac439a4e7495b185a64af9b7b9300", + "revCount": 10, + "type": "git", + "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" + }, + "original": { + "ref": "hkm/remote-iserv", + "type": "git", + "url": "https://gitlab.haskell.org/hamishmack/iserv-proxy.git" + } + }, "lowdown-src": { "flake": false, "locked": { @@ -451,10 +861,26 @@ "type": "github" } }, + "lowdown-src_2": { + "flake": false, + "locked": { + "lastModified": 1633514407, + "narHash": "sha256-Dw32tiMjdK9t3ETl5fzGrutQTzh2rufgZV4A/BbxuD4=", + "owner": "kristapsdz", + "repo": "lowdown", + "rev": "d2c2b44ff6c27b936ec27358a2653caaef8f73b8", + "type": "github" + }, + "original": { + "owner": "kristapsdz", + "repo": "lowdown", + "type": "github" + } + }, "mac2ios": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1699767871, @@ -470,6 +896,35 @@ "type": "github" } }, + "n2c": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1665039323, + "narHash": "sha256-SAh3ZjFGsaCI8FRzXQyp56qcGdAqgKEfJWPCQ0Sr7tQ=", + "owner": "nlewo", + "repo": "nix2container", + "rev": "b008fe329ffb59b67bf9e7b08ede6ee792f2741a", + "type": "github" + }, + "original": { + "owner": "nlewo", + "repo": "nix2container", + "type": "github" + } + }, "nix": { "inputs": { "lowdown-src": "lowdown-src", @@ -491,6 +946,116 @@ "type": "github" } }, + "nix-nomad": { + "inputs": { + "flake-compat": "flake-compat_3", + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "nix2container", + "flake-utils" + ], + "gomod2nix": "gomod2nix", + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "nixpkgs" + ], + "nixpkgs-lib": [ + "haskellNix-ghc8107", + "tullia", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1658277770, + "narHash": "sha256-T/PgG3wUn8Z2rnzfxf2VqlR1CBjInPE0l1yVzXxPnt0=", + "owner": "tristanpemble", + "repo": "nix-nomad", + "rev": "054adcbdd0a836ae1c20951b67ed549131fd2d70", + "type": "github" + }, + "original": { + "owner": "tristanpemble", + "repo": "nix-nomad", + "type": "github" + } + }, + "nix2container": { + "inputs": { + "flake-utils": "flake-utils_3", + "nixpkgs": "nixpkgs_4" + }, + "locked": { + "lastModified": 1658567952, + "narHash": "sha256-XZ4ETYAMU7XcpEeAFP3NOl9yDXNuZAen/aIJ84G+VgA=", + "owner": "nlewo", + "repo": "nix2container", + "rev": "60bb43d405991c1378baf15a40b5811a53e32ffa", + "type": "github" + }, + "original": { + "owner": "nlewo", + "repo": "nix2container", + "type": "github" + } + }, + "nix_2": { + "inputs": { + "lowdown-src": "lowdown-src_2", + "nixpkgs": "nixpkgs_2", + "nixpkgs-regression": "nixpkgs-regression_2" + }, + "locked": { + "lastModified": 1661606874, + "narHash": "sha256-9+rpYzI+SmxJn+EbYxjGv68Ucp22bdFUSy/4LkHkkDQ=", + "owner": "NixOS", + "repo": "nix", + "rev": "11e45768b34fdafdcf019ddbd337afa16127ff0f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "2.11.0", + "repo": "nix", + "type": "github" + } + }, + "nixago": { + "inputs": { + "flake-utils": [ + "haskellNix-ghc8107", + "tullia", + "std", + "flake-utils" + ], + "nixago-exts": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1661824785, + "narHash": "sha256-/PnwdWoO/JugJZHtDUioQp3uRiWeXHUdgvoyNbXesz8=", + "owner": "nix-community", + "repo": "nixago", + "rev": "8c1f9e5f1578d4b2ea989f618588d62a335083c3", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixago", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1657693803, @@ -523,6 +1088,22 @@ "type": "github" } }, + "nixpkgs-2003_2": { + "locked": { + "lastModified": 1620055814, + "narHash": "sha256-8LEHoYSJiL901bTMVatq+rf8y7QtWuZhwwpKE2fyaRY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1db42b7fe3878f3f5f7a4f2dc210772fd080e205", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-20.03-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2105": { "locked": { "lastModified": 1659914493, @@ -539,6 +1120,22 @@ "type": "github" } }, + "nixpkgs-2105_2": { + "locked": { + "lastModified": 1659914493, + "narHash": "sha256-lkA5X3VNMKirvA+SUzvEhfA7XquWLci+CGi505YFAIs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "022caabb5f2265ad4006c1fa5b1ebe69fb0c3faf", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-21.05-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2111": { "locked": { "lastModified": 1659446231, @@ -555,6 +1152,22 @@ "type": "github" } }, + "nixpkgs-2111_2": { + "locked": { + "lastModified": 1659446231, + "narHash": "sha256-hekabNdTdgR/iLsgce5TGWmfIDZ86qjPhxDg/8TlzhE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "eabc38219184cc3e04a974fe31857d8e0eac098d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-21.11-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2205": { "locked": { "lastModified": 1685573264, @@ -571,6 +1184,22 @@ "type": "github" } }, + "nixpkgs-2205_2": { + "locked": { + "lastModified": 1672580127, + "narHash": "sha256-3lW3xZslREhJogoOkjeZtlBtvFMyxHku7I/9IVehhT8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0874168639713f547c05947c76124f78441ea46c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-22.05-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2211": { "locked": { "lastModified": 1688392541, @@ -587,6 +1216,22 @@ "type": "github" } }, + "nixpkgs-2211_2": { + "locked": { + "lastModified": 1675730325, + "narHash": "sha256-uNvD7fzO5hNlltNQUAFBPlcEjNG5Gkbhl/ROiX+GZU4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b7ce17b1ebf600a72178f6302c77b6382d09323f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-22.11-darwin", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs-2305": { "locked": { "lastModified": 1705033721, @@ -619,6 +1264,22 @@ "type": "github" } }, + "nixpkgs-ghc8107": { + "locked": { + "lastModified": 1676726892, + "narHash": "sha256-M7OYVR6dKmzmlebIjybFf3l18S2uur8lMyWWnHQooLY=", + "owner": "angerman", + "repo": "nixpkgs", + "rev": "729469087592bdea58b360de59dadf6d58714c42", + "type": "github" + }, + "original": { + "owner": "angerman", + "repo": "nixpkgs", + "rev": "729469087592bdea58b360de59dadf6d58714c42", + "type": "github" + } + }, "nixpkgs-lib": { "locked": { "dir": "lib", @@ -653,6 +1314,22 @@ "type": "github" } }, + "nixpkgs-regression_2": { + "locked": { + "lastModified": 1643052045, + "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + } + }, "nixpkgs-unstable": { "locked": { "lastModified": 1694822471, @@ -669,7 +1346,86 @@ "type": "github" } }, + "nixpkgs-unstable_2": { + "locked": { + "lastModified": 1675758091, + "narHash": "sha256-7gFSQbSVAFUHtGCNHPF7mPc5CcqDk9M2+inlVPZSneg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "747927516efcb5e31ba03b7ff32f61f6d47e7d87", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_2": { + "locked": { + "lastModified": 1657693803, + "narHash": "sha256-G++2CJ9u0E7NNTAi9n5G8TdDmGJXcIjkJ3NF8cetQB8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "365e1b3a859281cf11b94f87231adeabbdd878a2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-22.05-small", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "locked": { + "lastModified": 1653581809, + "narHash": "sha256-Uvka0V5MTGbeOfWte25+tfRL3moECDh1VwokWSZUdoY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "83658b28fe638a170a19b8933aa008b30640fbd1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_4": { + "locked": { + "lastModified": 1654807842, + "narHash": "sha256-ADymZpr6LuTEBXcy6RtFHcUZdjKTBRTMYwu19WOx17E=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fc909087cc3386955f21b4665731dbdaceefb1d8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_5": { + "locked": { + "lastModified": 1665087388, + "narHash": "sha256-FZFPuW9NWHJteATOf79rZfwfRn5fE0wi9kRzvGfDHPA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "95fda953f6db2e9496d2682c4fc7b82f959878f7", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_6": { "locked": { "lastModified": 1698434055, "narHash": "sha256-Phxi5mUKSoL7A0IYUiYtkI9e8NcGaaV5PJEaJApU1Ko=", @@ -685,6 +1441,21 @@ "type": "github" } }, + "nosys": { + "locked": { + "lastModified": 1667881534, + "narHash": "sha256-FhwJ15uPLRsvaxtt/bNuqE/ykMpNAPF0upozFKhTtXM=", + "owner": "divnix", + "repo": "nosys", + "rev": "2d0d5207f6a230e9d0f660903f8db9807b54814f", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "nosys", + "type": "github" + } + }, "old-ghc-nix": { "flake": false, "locked": { @@ -702,16 +1473,36 @@ "type": "github" } }, + "old-ghc-nix_2": { + "flake": false, + "locked": { + "lastModified": 1631092763, + "narHash": "sha256-sIKgO+z7tj4lw3u6oBZxqIhDrzSkvpHtv0Kki+lh9Fg=", + "owner": "angerman", + "repo": "old-ghc-nix", + "rev": "af48a7a7353e418119b6dfe3cd1463a657f342b8", + "type": "github" + }, + "original": { + "owner": "angerman", + "ref": "master", + "repo": "old-ghc-nix", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", "hackage": "hackage", + "hackage-ghc8107": "hackage-ghc8107", "haskellNix": "haskellNix", + "haskellNix-ghc8107": "haskellNix-ghc8107", "mac2ios": "mac2ios", "nixpkgs": [ "haskellNix", "nixpkgs-2305" - ] + ], + "nixpkgs-ghc8107": "nixpkgs-ghc8107" } }, "stackage": { @@ -730,6 +1521,67 @@ "type": "github" } }, + "stackage_2": { + "flake": false, + "locked": { + "lastModified": 1677888571, + "narHash": "sha256-YkhRNOaN6QVagZo1cfykYV8KqkI8/q6r2F5+jypOma4=", + "owner": "input-output-hk", + "repo": "stackage.nix", + "rev": "cb50e6fabdfb2d7e655059039012ad0623f06a27", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "stackage.nix", + "type": "github" + } + }, + "std": { + "inputs": { + "arion": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "blank": "blank", + "devshell": "devshell", + "dmerge": "dmerge", + "flake-utils": "flake-utils_4", + "incl": "incl", + "makes": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "microvm": [ + "haskellNix-ghc8107", + "tullia", + "std", + "blank" + ], + "n2c": "n2c", + "nixago": "nixago", + "nixpkgs": "nixpkgs_5", + "nosys": "nosys", + "yants": "yants" + }, + "locked": { + "lastModified": 1674526466, + "narHash": "sha256-tMTaS0bqLx6VJ+K+ZT6xqsXNpzvSXJTmogkraBGzymg=", + "owner": "divnix", + "repo": "std", + "rev": "516387e3d8d059b50e742a2ff1909ed3c8f82826", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "std", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, @@ -744,6 +1596,68 @@ "repo": "default", "type": "github" } + }, + "tullia": { + "inputs": { + "nix-nomad": "nix-nomad", + "nix2container": "nix2container", + "nixpkgs": [ + "haskellNix-ghc8107", + "nixpkgs" + ], + "std": "std" + }, + "locked": { + "lastModified": 1675695930, + "narHash": "sha256-B7rEZ/DBUMlK1AcJ9ajnAPPxqXY6zW2SBX+51bZV0Ac=", + "owner": "input-output-hk", + "repo": "tullia", + "rev": "621365f2c725608f381b3ad5b57afef389fd4c31", + "type": "github" + }, + "original": { + "owner": "input-output-hk", + "repo": "tullia", + "type": "github" + } + }, + "utils": { + "locked": { + "lastModified": 1653893745, + "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "yants": { + "inputs": { + "nixpkgs": [ + "haskellNix-ghc8107", + "tullia", + "std", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1667096281, + "narHash": "sha256-wRRec6ze0gJHmGn6m57/zhz/Kdvp9HS4Nl5fkQ+uIuA=", + "owner": "divnix", + "repo": "yants", + "rev": "d18f356ec25cb94dc9c275870c3a7927a10f8c3c", + "type": "github" + }, + "original": { + "owner": "divnix", + "repo": "yants", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 84f84c34ec..26ece60cbf 100644 --- a/flake.nix +++ b/flake.nix @@ -8,8 +8,17 @@ flake = false; }; inputs.haskellNix.inputs.hackage.follows = "hackage"; + # armv7a android is built with GHC 8.10.7; its toolchain is pinned by revision so updates of the inputs above don't move it. + inputs.haskellNix-ghc8107.url = "github:input-output-hk/haskell.nix/ab5efd87ce3fd8ade38a01d97693d29a4f1ae7e4"; + inputs.nixpkgs-ghc8107.url = "github:angerman/nixpkgs/729469087592bdea58b360de59dadf6d58714c42"; + inputs.hackage-ghc8107 = { + url = "github:input-output-hk/hackage.nix/24617c569995e38bf3b83b48eec6628a50fdb4fb"; + flake = false; + }; + inputs.haskellNix-ghc8107.inputs.nixpkgs.follows = "nixpkgs-ghc8107"; + inputs.haskellNix-ghc8107.inputs.hackage.follows = "hackage-ghc8107"; inputs.flake-utils.url = "github:numtide/flake-utils"; - outputs = { self, haskellNix, nixpkgs, flake-utils, mac2ios, ... }: + outputs = { self, haskellNix, haskellNix-ghc8107, nixpkgs, flake-utils, mac2ios, ... }: let systems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; in flake-utils.lib.eachSystem systems (system: # this android26 overlay makes the pkgsCross.{aarch64-android,armv7a-android-prebuilt} to set stdVer to 26 (Android 8). @@ -29,8 +38,9 @@ }; in # `appendOverlays` with a singleton is identical to `extend`. let pkgs = haskellNix.legacyPackages.${system}.appendOverlays [android26]; in - let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { - compiler-nix-name = "ghc963"; + let pkgs8107 = haskellNix-ghc8107.legacyPackages.${system}.appendOverlays [android26]; in + let drv' = { extra-modules, pkgs', compiler-nix-name ? "ghc963", ... }: pkgs'.haskell-nix.project { + inherit compiler-nix-name; index-state = "2023-12-12T00:00:00Z"; # We need this, to specify we want the cabal project. # If the stack.yaml was dropped, this would not be necessary. @@ -126,16 +136,20 @@ "x86_64-linux" = let androidPkgs = pkgs.pkgsCross.aarch64-android; - android32Pkgs = pkgs.pkgsCross.armv7a-android-prebuilt; + android32Pkgs = pkgs8107.pkgsCross.armv7a-android-prebuilt; # For some reason building libiconv with nixpgks android setup produces # LANGINFO_CODESET to be found, which is not compatible with android sdk 23; # so we'll patch up iconv to not include that. - androidIconv = (androidPkgs.libiconv.override { enableStatic = true; }).overrideAttrs (old: { + mkAndroidIconv = androidPkgs: (androidPkgs.libiconv.override { enableStatic = true; }).overrideAttrs (old: { postConfigure = '' echo "#undef HAVE_LANGINFO_CODESET" >> libcharset/config.h echo "#undef HAVE_LANGINFO_CODESET" >> lib/config.h ''; }); + androidIconv = mkAndroidIconv androidPkgs; + # Only interpolated in a commented-out line of armv7a postInstall; it must come from the + # GHC 8.10.7 nixpkgs to keep that derivation identical to the former master-android build. + androidIconv8107 = mkAndroidIconv pkgs8107.pkgsCross.aarch64-android; # Similarly to icovn, for reasons beyond my current knowledge, nixpkgs andorid # toolchain makes configure believe we have MEMFD_CREATE, which we don't in # sdk 23. @@ -275,37 +289,22 @@ # STATIC aarch64-linux "${pkgs.pkgsCross.aarch64-multiplatform-musl.hostPlatform.system}-static:exe:simplex-chat" = (drv pkgs.pkgsCross.aarch64-multiplatform-musl).simplex-chat.components.exes.simplex-chat; - "armv7a-android:lib:support" = (drv android32Pkgs).android-support.components.library.override (p: { - smallAddressSpace = true; - # we won't want -dyamic (see aarch64-android:lib:simplex-chat) - enableShared = false; - # we also do not want to have any dependencies listed (especially no rts!) - enableStatic = false; - - # This used to work with 8.10.7... - # setupBuildFlags = p.component.setupBuildFlags ++ map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; - # ... but now with 9.6+ - # we have to do the -shared thing by hand. - postBuild = '' - armv7a-unknown-linux-androideabi-ghc -shared -o libsupport.so \ - -optl-Wl,-u,setLineBuffering \ - -optl-Wl,-u,pipe_std_to_socket \ - dist/build/*.a - ''; - + "armv7a-android:lib:support" = (drv' { pkgs' = android32Pkgs; extra-modules = []; compiler-nix-name = "ghc8107"; }).android-support.components.library.override { + smallAddressSpace = true; enableShared = false; + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ]; postInstall = '' mkdir -p $out/_pkg cp libsupport.so $out/_pkg - ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsupport.zip *) + ${pkgs8107.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so + (cd $out/_pkg; ${pkgs8107.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsupport.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ > $out/nix-support/hydra-build-products ''; - }); + }; # The android-support package is at # https://github.com/simplex-chat/android-support "aarch64-android:lib:support" = (drv androidPkgs).android-support.components.library.override (p: { @@ -340,10 +339,10 @@ }); "armv7a-android:lib:simplex-chat" = (drv' { pkgs' = android32Pkgs; + compiler-nix-name = "ghc8107"; extra-modules = [{ - packages.text.flags.simdutf = false; packages.direct-sqlcipher.flags.openssl = true; - packages.direct-sqlcipher.components.library.libs = pkgs.lib.mkForce [ + packages.direct-sqlcipher.components.library.libs = pkgs8107.lib.mkForce [ (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) ]; packages.direct-sqlcipher.patches = [ @@ -351,70 +350,21 @@ ]; packages.simplex-chat.flags.client_library = true; packages.simplexmq.flags.client_library = true; - packages.simplexmq.components.library.libs = pkgs.lib.mkForce [ + packages.simplexmq.components.library.libs = pkgs8107.lib.mkForce [ (android32Pkgs.openssl.override { static = true; enableKTLS = false; }) ]; - # 32 bit patches - packages.basement.patches = [ - ./scripts/nix/basement-pr-573.patch - ]; - packages.memory.patches = [ - ./scripts/nix/memory-pr-99.patch - ]; }]; - }).simplex-chat.components.library.override (p: { - smallAddressSpace = true; - # we want -shared, but not -dyanmic, hence `enableShared = false`. - enableShared = false; - # we _do_ want rts, and other libs. Hence `enableStatic = true`. - enableStatic = true; + }).simplex-chat.components.library.override { + smallAddressSpace = true; enableShared = false; # for android we build a shared library, passing these arguments is a bit tricky, as # we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for # template haskell cross compilation. Thus we just pass them as linker options (-optl). - setupBuildFlags = p.component.setupBuildFlags - # flags to tell GHC we want to produce a -shared object, and we want to also link - # - the ffi library (ffi) - ++ map (x: "--ghc-option=${x}") [ - "-shared" "-o" "libsimplex.so" - "-threaded" - # "-debug" - "-optl-lffi" - "-j1" - ] - # This is fairly idiotic. LLD will strip out foreign exported - # symbols (a GHC bug? Codegen bug?). So we need to pass `-u ` - # to ensure they stay in the produced library. Having them - # _undefined_ and _lazy_ (lld will tell with -y that the - # symbol is lazy), makes them _defined_. m( - ++ map (sym: "--ghc-option=-optl-Wl,-u,${sym}") [ - "chat_close_store" - "chat_decrypt_file" - "chat_decrypt_media" - "chat_encrypt_file" - "chat_encrypt_media" - "chat_migrate_init" - "chat_migrate_init_queue" - "chat_parse_markdown" - "chat_parse_server" - "chat_parse_uri" - "chat_password_hash" - "chat_read_file" - "chat_recv_msg" - "chat_recv_msg_wait" - "chat_send_cmd" - "chat_send_cmd_retry" - "chat_send_remote_cmd" - "chat_send_remote_cmd_retry" - "chat_valid_name" - "chat_parse_badge_code" - "chat_json_length" - "chat_badge_keygen" - "chat_badge_issue" - "chat_write_file" - ]; + # 32-bit ARM only: processAgentMessageConn is too large for ARM's PC-relative constant-pool + # range at -O2; -O1 is appended after the cabal -O2 here, so it wins (aarch64 stays -O2). + setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi" "-j1" "-O1"]; postInstall = '' set -x - ${pkgs.tree}/bin/tree $out + ${pkgs8107.tree}/bin/tree $out mkdir -p $out/_pkg # copy over includes, we might want those, but maybe not. # cp -r $out/lib/*/*/include $out/_pkg/ @@ -425,7 +375,7 @@ # find ./dist -name "libHS*-ghc*.a" -exec cp {} $out/_pkg \; # find ${android32FFI}/lib -name "*.a" -exec cp {} $out/_pkg \; # find ${android32Pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; - # find ${androidIconv}/lib -name "*.a" -exec cp {} $out/_pkg \; + # find ${androidIconv8107}/lib -name "*.a" -exec cp {} $out/_pkg \; # find ${android32Pkgs.stdenv.cc.libc}/lib -name "*.a" -exec cp {} $out/_pkg \; echo ${android32Pkgs.openssl.override { enableKTLS = false; }} find ${(android32Pkgs.openssl.override { enableKTLS = false; }).out}/lib -name "*.so" -exec cp {} $out/_pkg \; @@ -433,22 +383,22 @@ # remove the .1 and other version suffixes from .so's. Androids linker # doesn't play nice with them. for lib in $out/_pkg/*.so; do - for dep in $(${pkgs.patchelf}/bin/patchelf --print-needed "$lib"); do + for dep in $(${pkgs8107.patchelf}/bin/patchelf --print-needed "$lib"); do if [[ "''${dep##*.so}" ]]; then echo "$lib : $dep -> ''${dep%%.so*}.so" chmod +w "$lib" - ${pkgs.patchelf}/bin/patchelf --replace-needed "$dep" "''${dep%%.so*}.so" "$lib" + ${pkgs8107.patchelf}/bin/patchelf --replace-needed "$dep" "''${dep%%.so*}.so" "$lib" fi done done for lib in $out/_pkg/*.so; do chmod +w "$lib" - ${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so "$lib" - [[ "$lib" != *libsimplex.so ]] && ${pkgs.patchelf}/bin/patchelf --set-soname "$(basename -a $lib)" "$lib" + ${pkgs8107.patchelf}/bin/patchelf --remove-needed libunwind.so "$lib" + [[ "$lib" != *libsimplex.so ]] && ${pkgs8107.patchelf}/bin/patchelf --set-soname "$(basename -a $lib)" "$lib" done - ${pkgs.tree}/bin/tree $out/_pkg + ${pkgs8107.tree}/bin/tree $out/_pkg # Strip from debug symbols find "$out/_pkg" -type f -name "*.so" -exec ${android32Pkgs.stdenv.cc.targetPrefix}strip --strip-unneeded {} + @@ -458,13 +408,13 @@ find "$out/_pkg" -type d -exec chmod 755 {} + find "$out/_pkg" -exec touch -h -d '@1764547200' {} + - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) + (cd $out/_pkg; ${pkgs8107.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ > $out/nix-support/hydra-build-products ''; - }); + }; "aarch64-android:lib:simplex-chat" = (drv' { pkgs' = androidPkgs; extra-modules = [{ diff --git a/packages/simplex-chat-client/types/typescript/README.md b/packages/simplex-chat-client/types/typescript/README.md index e30cf0d0c3..8d5f88657d 100644 --- a/packages/simplex-chat-client/types/typescript/README.md +++ b/packages/simplex-chat-client/types/typescript/README.md @@ -4,6 +4,8 @@ This TypeScript library provides auto-generated types for bots API: commands and It is used in [simplex-chat](https://www.npmjs.com/package/simplex-chat) Node.js library. +SimpleX Chat core: 7.1.0.12 + [API reference](https://github.com/simplex-chat/simplex-chat/tree/stable/bots). ## License diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 24ea58b66b..983292abd1 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,7 @@ { "name": "@simplex-chat/types", - "version": "0.12.0", + "version": "7.1.0-beta.6.1", + "simplexChatCore": "7.1.0.12", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index cc01ae9472..fc1530f674 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -266,6 +266,23 @@ export namespace APIAddMember { } } +// Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +// Network usage: interactive. +export interface APIAddMemberViaGroup { + groupId: number // int64 + viaGroupId: number // int64 + groupMemberId: number // int64 + memberRole: T.GroupMemberRole +} + +export namespace APIAddMemberViaGroup { + export type Response = CR.SentGroupInvitationViaGroup | CR.ChatCmdError + + export function cmdString(self: APIAddMemberViaGroup): string { + return '/_add #' + self.groupId + ' #' + self.viaGroupId + ' ' + self.groupMemberId + ' ' + self.memberRole + } +} + // Join group. // Network usage: interactive. export interface APIJoinGroup { diff --git a/packages/simplex-chat-client/types/typescript/src/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts index 5b644ee41e..b1e8fc6057 100644 --- a/packages/simplex-chat-client/types/typescript/src/events.ts +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -17,6 +17,7 @@ export type ChatEvent = | CEvt.GroupChatItemsDeleted | CEvt.ChatItemsStatusesUpdated | CEvt.ReceivedGroupInvitation + | CEvt.ReceivedGroupInvitationViaGroup | CEvt.UserJoinedGroup | CEvt.GroupUpdated | CEvt.JoinedGroupMember @@ -73,6 +74,7 @@ export namespace CEvt { | "groupChatItemsDeleted" | "chatItemsStatusesUpdated" | "receivedGroupInvitation" + | "receivedGroupInvitationViaGroup" | "userJoinedGroup" | "groupUpdated" | "joinedGroupMember" @@ -210,6 +212,16 @@ export namespace CEvt { memberRole: T.GroupMemberRole } + export interface ReceivedGroupInvitationViaGroup extends Interface { + type: "receivedGroupInvitationViaGroup" + user: T.User + groupInfo: T.GroupInfo + viaGroupInfo: T.GroupInfo + viaMember: T.GroupMember + fromMemberRole: T.GroupMemberRole + memberRole: T.GroupMemberRole + } + export interface UserJoinedGroup extends Interface { type: "userJoinedGroup" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 647427f0dc..ef83e89e9d 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -51,6 +51,7 @@ export type ChatResponse = | CR.RemoteCtrlConnecting | CR.SentConfirmation | CR.SentGroupInvitation + | CR.SentGroupInvitationViaGroup | CR.SentInvitation | CR.SentInvitationToContact | CR.ServiceReplyAccepted @@ -117,6 +118,7 @@ export namespace CR { | "remoteCtrlConnecting" | "sentConfirmation" | "sentGroupInvitation" + | "sentGroupInvitationViaGroup" | "sentInvitation" | "sentInvitationToContact" | "serviceReplyAccepted" @@ -445,6 +447,15 @@ export namespace CR { member: T.GroupMember } + export interface SentGroupInvitationViaGroup extends Interface { + type: "sentGroupInvitationViaGroup" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + viaGroupInfo: T.GroupInfo + viaMember: T.GroupMember + } + export interface SentInvitation extends Interface { type: "sentInvitation" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index d7db7f259f..cef7f41de5 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -249,6 +249,7 @@ export interface BadgeProof { export type BadgeRedeemError = | BadgeRedeemError.InvalidCode + | BadgeRedeemError.InvalidReceipt | BadgeRedeemError.ServiceNotConfigured | BadgeRedeemError.BadgeActive | BadgeRedeemError.ServiceError @@ -259,6 +260,7 @@ export type BadgeRedeemError = export namespace BadgeRedeemError { export type Tag = | "invalidCode" + | "invalidReceipt" | "serviceNotConfigured" | "badgeActive" | "serviceError" @@ -274,6 +276,10 @@ export namespace BadgeRedeemError { type: "invalidCode" } + export interface InvalidReceipt extends Interface { + type: "invalidReceipt" + } + export interface ServiceNotConfigured extends Interface { type: "serviceNotConfigured" } @@ -312,12 +318,14 @@ export enum BadgeServiceErrorCode { Payment_not_entitled = "payment_not_entitled", Payment_pending = "payment_pending", Provider_unavailable = "provider_unavailable", + Provider_not_configured = "provider_not_configured", Rate_limited = "rate_limited", Code_invalid = "code_invalid", Code_used = "code_used", Code_expired = "code_expired", Receipt_invalid = "receipt_invalid", Receipt_used = "receipt_used", + Test_purchase = "test_purchase", Internal = "internal", } @@ -335,6 +343,112 @@ export enum BadgeType { Investor = "investor", } +export type BlockToken = + | BlockToken.LineBreak + | BlockToken.SoftBreak + | BlockToken.HardBreak + | BlockToken.ParagraphBreak + | BlockToken.Indent + | BlockToken.HeadingMark + | BlockToken.HeadingClose + | BlockToken.HeadingUnderline + | BlockToken.ListMark + | BlockToken.QuoteMark + | BlockToken.FenceMark + | BlockToken.RowMark + | BlockToken.CellMark + | BlockToken.DelimiterRow + | BlockToken.RuleMark + +export namespace BlockToken { + export type Tag = + | "lineBreak" + | "softBreak" + | "hardBreak" + | "paragraphBreak" + | "indent" + | "headingMark" + | "headingClose" + | "headingUnderline" + | "listMark" + | "quoteMark" + | "fenceMark" + | "rowMark" + | "cellMark" + | "delimiterRow" + | "ruleMark" + + interface Interface { + type: Tag + } + + export interface LineBreak extends Interface { + type: "lineBreak" + } + + export interface SoftBreak extends Interface { + type: "softBreak" + } + + export interface HardBreak extends Interface { + type: "hardBreak" + } + + export interface ParagraphBreak extends Interface { + type: "paragraphBreak" + lineBreaks: number // int + } + + export interface Indent extends Interface { + type: "indent" + } + + export interface HeadingMark extends Interface { + type: "headingMark" + level: number // int + } + + export interface HeadingClose extends Interface { + type: "headingClose" + } + + export interface HeadingUnderline extends Interface { + type: "headingUnderline" + level: number // int + } + + export interface ListMark extends Interface { + type: "listMark" + number?: string + } + + export interface QuoteMark extends Interface { + type: "quoteMark" + } + + export interface FenceMark extends Interface { + type: "fenceMark" + language?: string + } + + export interface RowMark extends Interface { + type: "rowMark" + } + + export interface CellMark extends Interface { + type: "cellMark" + } + + export interface DelimiterRow extends Interface { + type: "delimiterRow" + columns: TableAlign[] + } + + export interface RuleMark extends Interface { + type: "ruleMark" + } +} + export interface BlockingInfo { reason: BlockingReason notice?: ClientNotice @@ -990,6 +1104,7 @@ export interface CIQuote { sentAt: string // ISO-8601 timestamp content: MsgContent formattedText?: FormattedText[] + markdownBlocks?: MarkdownBlock[] } export interface CIReaction { @@ -1752,6 +1867,7 @@ export interface ChatItem { content: CIContent mentions: {[key: string]: CIMention} formattedText?: FormattedText[] + markdownBlocks?: MarkdownBlock[] quotedItem?: CIQuote reactions: CIReactionCount[] file?: CIFile @@ -1788,6 +1904,7 @@ export enum ChatPeerType { Human = "human", Bot = "bot", Business = "business", + Agent = "agent", } // Used in API commands. Chat scope can only be passed with groups. @@ -2560,6 +2677,7 @@ export enum FileType { Normal = "normal", Roster = "roster", } +// The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. export type Format = | Format.Bold @@ -2577,6 +2695,8 @@ export type Format = | Format.Mention | Format.Email | Format.Phone + | Format.Escape + | Format.Markup export namespace Format { export type Tag = @@ -2595,6 +2715,8 @@ export namespace Format { | "mention" | "email" | "phone" + | "escape" + | "markup" interface Interface { type: Tag @@ -2602,6 +2724,7 @@ export namespace Format { export interface Bold extends Interface { type: "bold" + singleStar: boolean } export interface Italic extends Interface { @@ -2669,6 +2792,15 @@ export namespace Format { export interface Phone extends Interface { type: "phone" } + + export interface Escape extends Interface { + type: "escape" + } + + export interface Markup extends Interface { + type: "markup" + token: BlockToken + } } export interface FormattedText { @@ -3133,6 +3265,12 @@ export interface LinkPreview { content?: LinkContent } +export interface ListEntry { + number?: string + content: SegmentRange + items: ListEntry[] +} + export interface LocalBadge { badge: BadgeInfo status: BadgeStatus @@ -3154,6 +3292,62 @@ export interface LocalProfile { contactDomainVerified?: boolean } +export type MarkdownBlock = + | MarkdownBlock.Paragraph + | MarkdownBlock.Heading + | MarkdownBlock.CodeBlock + | MarkdownBlock.Quote + | MarkdownBlock.List + | MarkdownBlock.Table + | MarkdownBlock.Rule + +export namespace MarkdownBlock { + export type Tag = "paragraph" | "heading" | "codeBlock" | "quote" | "list" | "table" | "rule" + + interface Interface { + type: Tag + } + + export interface Paragraph extends Interface { + type: "paragraph" + content: SegmentRange + } + + export interface Heading extends Interface { + type: "heading" + level: number // int + content: SegmentRange + } + + export interface CodeBlock extends Interface { + type: "codeBlock" + language?: string + content: SegmentRange + } + + export interface Quote extends Interface { + type: "quote" + content: SegmentRange + } + + export interface List extends Interface { + type: "list" + items: ListEntry[] // non-empty + } + + export interface Table extends Interface { + type: "table" + columns: TableAlign[] // non-empty + header: SegmentRange[] + rows: SegmentRange[][] + } + + export interface Rule extends Interface { + type: "rule" + mark: SegmentRange + } +} + export enum MemberCriteria { All = "all", } @@ -4207,6 +4401,11 @@ export interface SecurityCode { verifiedAt: string // ISO-8601 timestamp } +export interface SegmentRange { + from: number // int + to: number // int +} + export interface SimplePreference { allow: FeatureAllowed } @@ -5122,6 +5321,13 @@ export enum SwitchPhase { Completed = "completed", } +export enum TableAlign { + None = "none", + Left = "left", + Center = "center", + Right = "right", +} + export interface TimedMessagesGroupPreference { enable: GroupFeatureEnabled ttl?: number // int diff --git a/packages/simplex-chat-client/typescript/src/client.ts b/packages/simplex-chat-client/typescript/src/client.ts index 10c4ee0a81..67e470e52d 100644 --- a/packages/simplex-chat-client/typescript/src/client.ts +++ b/packages/simplex-chat-client/typescript/src/client.ts @@ -261,6 +261,12 @@ export class ChatClient { throw new ChatCommandError("error adding member", r) } + async apiAddMemberViaGroup(groupId: number, viaGroupId: number, groupMemberId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})) + if (r.type === "sentGroupInvitationViaGroup") return r.member + throw new ChatCommandError("error adding member", r) + } + async apiJoinGroup(groupId: number): Promise { const r = await this.sendChatCmd(CC.APIJoinGroup.cmdString({groupId})) if (r.type === "userAcceptedGroupSent") return r.groupInfo diff --git a/packages/simplex-chat-nodejs/.gitignore b/packages/simplex-chat-nodejs/.gitignore index 322e38bfda..4fa6477e38 100644 --- a/packages/simplex-chat-nodejs/.gitignore +++ b/packages/simplex-chat-nodejs/.gitignore @@ -2,7 +2,7 @@ node_modules/ package-lock.json .vscode build/ -libs/ dist/ coverage/ tmp/ +libs/ diff --git a/packages/simplex-chat-nodejs/.npmignore b/packages/simplex-chat-nodejs/.npmignore index 26fdd85dff..3e2e84b087 100644 --- a/packages/simplex-chat-nodejs/.npmignore +++ b/packages/simplex-chat-nodejs/.npmignore @@ -1,3 +1,2 @@ -libs/ build/ node_modules/ diff --git a/packages/simplex-chat-nodejs/README.md b/packages/simplex-chat-nodejs/README.md index 05c99eca5c..6ccd26fc05 100644 --- a/packages/simplex-chat-nodejs/README.md +++ b/packages/simplex-chat-nodejs/README.md @@ -55,40 +55,38 @@ If you cloned this repository, you can: cd ./packages/simplex-chat-nodejs npm install npm run build -node ./examples/squaring-bot-readme.js +SIMPLEX_ADDON_PATH=build/Release/simplex.node node ./examples/squaring-bot-readme.js ``` +Building the Node.js add-on from source needs [node-gyp](https://github.com/nodejs/node-gyp) build tools. + There is an example with more options in [./examples/squaring-bot.ts](./examples/squaring-bot.ts). You can run it with: `npx ts-node ./examples/squaring-bot.ts` A larger example, with a business address and commands as keys: [SimpleX Calculator](../../apps/simplex-calculator-bot/). -## PostgreSQL backend +## Native library -By default, the package uses SQLite. To use PostgreSQL instead: +`libsimplex` and the prebuilt Node.js add-on are downloaded on first use into the user cache (`$XDG_CACHE_HOME/simplex-chat` or `~/.cache/simplex-chat` on Linux, `~/Library/Caches/simplex-chat` on macOS, `%LOCALAPPDATA%\simplex-chat` on Windows), shared with the Python library. To download them ahead of time, for example in a Dockerfile, run as the user that runs the app: ```bash -npm install simplex-chat --simplex_backend=postgres +npx simplex-chat install # sqlite (default) +npx simplex-chat install --backend postgres # Linux x86_64 only ``` -Or persist the setting in `.npmrc`: +Set `SIMPLEX_LIBS_DIR` to a directory with a local build of `libsimplex` and its dependencies to use it instead; it must be built for the backend in use. Set `SIMPLEX_ADDON_PATH` to a locally built add-on (`build/Release/simplex.node`) to use it instead of the downloaded one. -```ini -simplex_backend=postgres -``` +`ChatApi.init` loads the library for `DbConfig.type`; the low-level `core` functions require `core.loadLibrary(backend)` first. -### Prerequisites (PostgreSQL) +## PostgreSQL backend + +`DbConfig.type` selects the backend; one backend per process. - `libpq5` must be installed on the host system (`apt install libpq5` on Debian/Ubuntu) - PostgreSQL backend is only available for Linux x86_64 - A PostgreSQL server accessible via connection string -### Passing PostgreSQL connection - -The `DbConfig` type is a discriminated union — pick the variant that matches -the backend you installed: - ```ts // SQLite (default) dbOpts: {type: "sqlite", filePrefix: "./data/bot"} diff --git a/packages/simplex-chat-nodejs/binding.gyp b/packages/simplex-chat-nodejs/binding.gyp index cfa6d61039..de6dbfbfd4 100644 --- a/packages/simplex-chat-nodejs/binding.gyp +++ b/packages/simplex-chat-nodejs/binding.gyp @@ -15,36 +15,8 @@ "msvs_settings": { "VCCLCompilerTool": { "ExceptionHandling": 1 } }, "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ], "conditions": [ - ["OS=='mac'", { - "libraries": [ - "-L<(module_root_dir)/libs", - "-lsimplex" - ], - "xcode_settings": { - "OTHER_LDFLAGS": [ - "-Wl,-rpath,@loader_path/../../libs" - ] - } - }], ["OS=='linux'", { - "libraries": [ - "-L<(module_root_dir)/libs", - "-lsimplex" - ], - "ldflags": [ - "-Wl,-rpath,'$$ORIGIN'/../../libs" - ] - }], - ["OS=='win'", { - "libraries": [ - "<(module_root_dir)/libs/libsimplex.lib" - ], - "copies": [{ - "destination": "<(PRODUCT_DIR)", - "files": [ - "<(module_root_dir)/libs/*" - ] - }] + "libraries": [ "-ldl" ] }] ] } diff --git a/packages/simplex-chat-nodejs/cpp/simplex.cc b/packages/simplex-chat-nodejs/cpp/simplex.cc index bcca2b994a..6cc2172ee1 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.cc +++ b/packages/simplex-chat-nodejs/cpp/simplex.cc @@ -8,15 +8,102 @@ #include #include #include +#include #include #include #include +#ifdef _WIN32 +#include +#else +#include +#endif #include "simplex.h" namespace simplex { using namespace Napi; +struct Library { + std::string path; + hs_init_with_rtsopts_fn hs_init_with_rtsopts = nullptr; + hs_thread_done_fn hs_thread_done = nullptr; + chat_migrate_init_fn chat_migrate_init = nullptr; + chat_migrate_init_queue_fn chat_migrate_init_queue = nullptr; + chat_close_store_fn chat_close_store = nullptr; + chat_send_cmd_fn chat_send_cmd = nullptr; + chat_recv_msg_wait_fn chat_recv_msg_wait = nullptr; + chat_write_file_fn chat_write_file = nullptr; + chat_read_file_fn chat_read_file = nullptr; + chat_encrypt_file_fn chat_encrypt_file = nullptr; + chat_decrypt_file_fn chat_decrypt_file = nullptr; +}; + +static Library lib; +static std::atomic loaded{false}; +static std::mutex load_mutex; + +#ifdef _WIN32 +typedef HMODULE LibHandle; + +static LibHandle OpenLibrary(const std::string& path) { + int n = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0); + std::wstring wpath(n, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, &wpath[0], n); + // dependencies resolve from the libsimplex.dll, application and system dirs, not the cwd or PATH + return LoadLibraryExW(wpath.c_str(), nullptr, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); +} + +static void CloseLibrary(LibHandle h) { + FreeLibrary(h); +} + +static void* FindSymbol(LibHandle h, const char* name) { + return reinterpret_cast(GetProcAddress(h, name)); +} + +static std::string LastLoadError() { + DWORD code = GetLastError(); + char* msg = nullptr; + FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, code, 0, reinterpret_cast(&msg), 0, nullptr); + std::string err = msg ? msg : "error " + std::to_string(code); + LocalFree(msg); + // system messages end with CRLF + err.erase(err.find_last_not_of("\r\n") + 1); + return err; +} +#else +typedef void* LibHandle; + +static LibHandle OpenLibrary(const std::string& path) { + return dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); +} + +static void CloseLibrary(LibHandle h) { + dlclose(h); +} + +static void* FindSymbol(LibHandle h, const char* name) { + return dlsym(h, name); +} + +static std::string LastLoadError() { + const char* err = dlerror(); + return err ? err : "unknown error"; +} +#endif + +template +static void Resolve(LibHandle h, const char* name, F& fn) { + fn = reinterpret_cast(FindSymbol(h, name)); +} + +template +static void ResolveRequired(LibHandle h, const char* name, F& fn, std::string& missing) { + Resolve(h, name, fn); + if (fn == nullptr && missing.empty()) missing = name; +} + void haskell_init() { #ifdef _WIN32 // non-moving GC is broken on windows with GHC 9.4-9.6.3 @@ -40,7 +127,57 @@ void haskell_init() { nullptr}; #endif char **pargv = const_cast(argv); - hs_init_with_rtsopts(&argc, &pargv); + lib.hs_init_with_rtsopts(&argc, &pargv); +} + +static bool RequireLoaded(Env env) { + if (loaded) return true; + Error::New(env, "libsimplex is not loaded, call core.loadLibrary(backend) first").ThrowAsJavaScriptException(); + return false; +} + +Value Load(const CallbackInfo& args) { + Env env = args.Env(); + if (args.Length() < 1 || !args[0].IsString()) { + TypeError::New(env, "Expected string (libPath)").ThrowAsJavaScriptException(); + return env.Undefined(); + } + std::string path = args[0].As().Utf8Value(); + // worker_threads may load concurrently; the Haskell runtime must be initialized once per process + std::lock_guard lock(load_mutex); + if (loaded) { + if (path != lib.path) Error::New(env, "libsimplex already loaded from " + lib.path).ThrowAsJavaScriptException(); + return env.Undefined(); + } + LibHandle h = OpenLibrary(path); + if (h == nullptr) { + std::string err = LastLoadError(); + Error::New(env, "cannot load " + path + ": " + err).ThrowAsJavaScriptException(); + return env.Undefined(); + } + Library l; + l.path = path; + std::string missing; + ResolveRequired(h, "hs_init_with_rtsopts", l.hs_init_with_rtsopts, missing); + ResolveRequired(h, "chat_migrate_init", l.chat_migrate_init, missing); + ResolveRequired(h, "chat_close_store", l.chat_close_store, missing); + ResolveRequired(h, "chat_send_cmd", l.chat_send_cmd, missing); + ResolveRequired(h, "chat_recv_msg_wait", l.chat_recv_msg_wait, missing); + ResolveRequired(h, "chat_write_file", l.chat_write_file, missing); + ResolveRequired(h, "chat_read_file", l.chat_read_file, missing); + ResolveRequired(h, "chat_encrypt_file", l.chat_encrypt_file, missing); + ResolveRequired(h, "chat_decrypt_file", l.chat_decrypt_file, missing); + if (!missing.empty()) { + CloseLibrary(h); + Error::New(env, path + " does not export " + missing).ThrowAsJavaScriptException(); + return env.Undefined(); + } + Resolve(h, "chat_migrate_init_queue", l.chat_migrate_init_queue); + Resolve(h, "hs_thread_done", l.hs_thread_done); + lib = l; + haskell_init(); + loaded = true; + return env.Undefined(); } class ResultAsyncWorker : public AsyncWorker { @@ -287,7 +424,7 @@ class Receiver { request = std::move(queue_.front()); queue_.pop_front(); } - char* c_res = chat_recv_msg_wait(ctrl_, request.wait); + char* c_res = lib.chat_recv_msg_wait(ctrl_, request.wait); napi_status status = Settle(request.deferred, [c_res](Napi::Env env, Promise::Deferred& deferred) { if (c_res == nullptr) { deferred.Reject(Error::New(env, "chat_recv_msg_wait failed").Value()); @@ -312,7 +449,7 @@ class Receiver { } pending_->Tsfn().Release(); // Each OS thread that enters Haskell keeps an RTS task record until it calls hs_thread_done. - hs_thread_done(); + if (lib.hs_thread_done) lib.hs_thread_done(); } template @@ -361,6 +498,7 @@ ResultAsyncWorker::ResultProcessor MigrateResultProcessor() { Value ChatMigrateInit(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) { TypeError::New(env, "Expected three string arguments").ThrowAsJavaScriptException(); return env.Undefined(); @@ -375,7 +513,7 @@ Value ChatMigrateInit(const CallbackInfo& args) { auto execute_fn = [path, key, confirm](ResultAsyncWorker* worker) { chat_ctrl ctrl = nullptr; - char* c_res = chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl); + char* c_res = lib.chat_migrate_init(path.c_str(), key.c_str(), confirm.c_str(), &ctrl); worker->SetCtrl(reinterpret_cast(ctrl)); HandleCResult(worker, c_res, "chat_migrate_init"); }; @@ -388,6 +526,11 @@ Value ChatMigrateInit(const CallbackInfo& args) { Value ChatMigrateInitQueue(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); + if (lib.chat_migrate_init_queue == nullptr) { + Error::New(env, "loaded libsimplex does not export chat_migrate_init_queue; queue size needs a newer libsimplex").ThrowAsJavaScriptException(); + return env.Undefined(); + } if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsNumber()) { TypeError::New(env, "Expected three string arguments and number").ThrowAsJavaScriptException(); return env.Undefined(); @@ -408,7 +551,7 @@ Value ChatMigrateInitQueue(const CallbackInfo& args) { auto execute_fn = [path, key, confirm, queue_size](ResultAsyncWorker* worker) { chat_ctrl ctrl = nullptr; - char* c_res = chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl); + char* c_res = lib.chat_migrate_init_queue(path.c_str(), key.c_str(), confirm.c_str(), queue_size, &ctrl); worker->SetCtrl(reinterpret_cast(ctrl)); HandleCResult(worker, c_res, "chat_migrate_init_queue"); }; @@ -421,6 +564,7 @@ Value ChatMigrateInitQueue(const CallbackInfo& args) { Value ChatCloseStore(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 1 || !args[0].IsBigInt()) { TypeError::New(env, "Expected bigint (ctrl)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -436,7 +580,7 @@ Value ChatCloseStore(const CallbackInfo& args) { if (receiver) { receiver->Stop(); } - char* c_res = chat_close_store(ctrl); + char* c_res = lib.chat_close_store(ctrl); HandleCResult(worker, c_res, "chat_close_store"); }; @@ -448,6 +592,7 @@ Value ChatCloseStore(const CallbackInfo& args) { Value ChatSendCmd(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsString()) { TypeError::New(env, "Expected bigint (ctrl) and string (cmd)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -460,7 +605,7 @@ Value ChatSendCmd(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [ctrl, cmd](ResultAsyncWorker* worker) { - char* c_res = chat_send_cmd(ctrl, cmd.c_str()); + char* c_res = lib.chat_send_cmd(ctrl, cmd.c_str()); HandleCResult(worker, c_res, "chat_send_cmd"); }; @@ -472,6 +617,7 @@ Value ChatSendCmd(const CallbackInfo& args) { Value ChatRecvMsgWait(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 2 || !args[0].IsBigInt() || !args[1].IsNumber()) { TypeError::New(env, "Expected bigint (ctrl), number (wait)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -503,6 +649,7 @@ Value ChatRecvMsgWait(const CallbackInfo& args) { Value ChatWriteFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !(args[2].IsArrayBuffer() || args[2].IsTypedArray())) { TypeError::New(env, "Expected bigint (ctrl), string (path), ArrayBuffer or Uint8Array").ThrowAsJavaScriptException(); return env.Undefined(); @@ -530,7 +677,7 @@ Value ChatWriteFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [ctrl, path, data, len](ResultAsyncWorker* worker) { - char* c_res = chat_write_file(ctrl, path.c_str(), data, static_cast(len)); + char* c_res = lib.chat_write_file(ctrl, path.c_str(), data, static_cast(len)); HandleCResult(worker, c_res, "chat_write_file"); }; @@ -543,6 +690,7 @@ Value ChatWriteFile(const CallbackInfo& args) { Value ChatReadFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString()) { TypeError::New(env, "Expected three strings (path, key, nonce)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -556,7 +704,7 @@ Value ChatReadFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [path, key, nonce](BinaryAsyncWorker* worker) { - char* buf = chat_read_file(path.c_str(), key.c_str(), nonce.c_str()); + char* buf = lib.chat_read_file(path.c_str(), key.c_str(), nonce.c_str()); if (buf == nullptr) { worker->SetWorkerError("chat_read_file failed"); return; @@ -586,6 +734,7 @@ Value ChatReadFile(const CallbackInfo& args) { Value ChatEncryptFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 3 || !args[0].IsBigInt() || !args[1].IsString() || !args[2].IsString()) { TypeError::New(env, "Expected bigint (ctrl), two strings (fromPath, toPath)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -599,7 +748,7 @@ Value ChatEncryptFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [ctrl, fromPath, toPath](ResultAsyncWorker* worker) { - char* c_res = chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str()); + char* c_res = lib.chat_encrypt_file(ctrl, fromPath.c_str(), toPath.c_str()); HandleCResult(worker, c_res, "chat_encrypt_file"); }; @@ -611,6 +760,7 @@ Value ChatEncryptFile(const CallbackInfo& args) { Value ChatDecryptFile(const CallbackInfo& args) { Env env = args.Env(); + if (!RequireLoaded(env)) return env.Undefined(); if (args.Length() < 4 || !args[0].IsString() || !args[1].IsString() || !args[2].IsString() || !args[3].IsString()) { TypeError::New(env, "Expected four strings (fromPath, key, nonce, toPath)").ThrowAsJavaScriptException(); return env.Undefined(); @@ -625,7 +775,7 @@ Value ChatDecryptFile(const CallbackInfo& args) { Promise promise = CreatePromiseAndCallback(env, cb); auto execute_fn = [fromPath, key, nonce, toPath](ResultAsyncWorker* worker) { - char* c_res = chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str()); + char* c_res = lib.chat_decrypt_file(fromPath.c_str(), key.c_str(), nonce.c_str(), toPath.c_str()); HandleCResult(worker, c_res, "chat_decrypt_file"); }; @@ -636,7 +786,7 @@ Value ChatDecryptFile(const CallbackInfo& args) { } Object Init(Env env, Object exports) { - haskell_init(); + exports.Set("load", Function::New(env, Load)); auto* receivers = new Receivers(); // Stopping all receivers before joining any bounds teardown by the longest in-flight receive. env.AddCleanupHook([receivers]() { diff --git a/packages/simplex-chat-nodejs/cpp/simplex.h b/packages/simplex-chat-nodejs/cpp/simplex.h index ddc052946a..7c44313295 100644 --- a/packages/simplex-chat-nodejs/cpp/simplex.h +++ b/packages/simplex-chat-nodejs/cpp/simplex.h @@ -9,40 +9,30 @@ #ifndef SimpleX_h #define SimpleX_h -extern "C" void hs_init(int argc, char **argv[]); -extern "C" void hs_init_with_rtsopts(int * argc, char **argv[]); -extern "C" void hs_thread_done(void); - typedef long* chat_ctrl; +typedef void (*hs_init_with_rtsopts_fn)(int *argc, char **argv[]); +typedef void (*hs_thread_done_fn)(void); // the last parameter is used to return the pointer to chat controller -extern "C" char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); -extern "C" char *chat_migrate_init_queue(const char *path, const char *key, const char *confirm, const int queueSize, chat_ctrl *ctrl); -extern "C" char *chat_close_store(chat_ctrl ctrl); -extern "C" char *chat_reopen_store(chat_ctrl ctrl); -extern "C" char *chat_send_cmd(chat_ctrl ctrl, const char *cmd); -extern "C" char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); -extern "C" char *chat_parse_markdown(const char *str); -extern "C" char *chat_parse_server(const char *str); -extern "C" char *chat_password_hash(const char *pwd, const char *salt); -extern "C" char *chat_valid_name(const char *name); -extern "C" int chat_json_length(const char *str); -extern "C" char *chat_encrypt_media(chat_ctrl ctrl, const char *key, const char *frame, const int len); -extern "C" char *chat_decrypt_media(const char *key, const char *frame, const int len); +typedef char *(*chat_migrate_init_fn)(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl); +typedef char *(*chat_migrate_init_queue_fn)(const char *path, const char *key, const char *confirm, int queueSize, chat_ctrl *ctrl); +typedef char *(*chat_close_store_fn)(chat_ctrl ctrl); +typedef char *(*chat_send_cmd_fn)(chat_ctrl ctrl, const char *cmd); +typedef char *(*chat_recv_msg_wait_fn)(chat_ctrl ctrl, int wait); // chat_write_file returns null-terminated string with JSON of WriteFileResult -extern "C" char *chat_write_file(chat_ctrl ctrl, const char *path, const char *data, const int len); +typedef char *(*chat_write_file_fn)(chat_ctrl ctrl, const char *path, const char *data, int len); // chat_read_file returns a buffer with: // result status (1 byte), then if // status == 0 (success): buffer length (uint32, 4 bytes), buffer of specified length. // status == 1 (error): null-terminated error message string. -extern "C" char *chat_read_file(const char *path, const char *key, const char *nonce); +typedef char *(*chat_read_file_fn)(const char *path, const char *key, const char *nonce); // chat_encrypt_file returns null-terminated string with JSON of WriteFileResult -extern "C" char *chat_encrypt_file(chat_ctrl ctrl, const char *fromPath, const char *toPath); +typedef char *(*chat_encrypt_file_fn)(chat_ctrl ctrl, const char *fromPath, const char *toPath); // chat_decrypt_file returns null-terminated string with the error message -extern "C" char *chat_decrypt_file(const char *fromPath, const char *key, const char *nonce, const char *toPath); +typedef char *(*chat_decrypt_file_fn)(const char *fromPath, const char *key, const char *nonce, const char *toPath); #endif /* simplex_h */ \ No newline at end of file diff --git a/packages/simplex-chat-nodejs/docs/Namespace.core.md b/packages/simplex-chat-nodejs/docs/Namespace.core.md index 82b0d9ffba..fdf9f8b4dd 100644 --- a/packages/simplex-chat-nodejs/docs/Namespace.core.md +++ b/packages/simplex-chat-nodejs/docs/Namespace.core.md @@ -32,6 +32,7 @@ You are unlikely to ever need to use this module directly. ## Type Aliases +- [Backend](core.TypeAlias.Backend.md) - [DBMigrationError](core.TypeAlias.DBMigrationError.md) - [MigrationError](core.TypeAlias.MigrationError.md) - [MTRError](core.TypeAlias.MTRError.md) @@ -46,3 +47,4 @@ You are unlikely to ever need to use this module directly. - [chatRecvMsgWait](core.Function.chatRecvMsgWait.md) - [chatSendCmd](core.Function.chatSendCmd.md) - [chatWriteFile](core.Function.chatWriteFile.md) +- [loadLibrary](core.Function.loadLibrary.md) diff --git a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md index 287099906b..cf293f55d9 100644 --- a/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md +++ b/packages/simplex-chat-nodejs/docs/api.Class.ChatApi.md @@ -6,7 +6,7 @@ # Class: ChatApi -Defined in: [src/api.ts:97](../src/api.ts#L97) +Defined in: [src/api.ts:95](../src/api.ts#L95) Main API class for interacting with the chat core library. @@ -16,7 +16,7 @@ Main API class for interacting with the chat core library. > `protected` **ctrl\_**: `bigint` \| `undefined` -Defined in: [src/api.ts:103](../src/api.ts#L103) +Defined in: [src/api.ts:101](../src/api.ts#L101) ## Accessors @@ -26,7 +26,7 @@ Defined in: [src/api.ts:103](../src/api.ts#L103) > **get** **ctrl**(): `bigint` -Defined in: [src/api.ts:344](../src/api.ts#L344) +Defined in: [src/api.ts:343](../src/api.ts#L343) Chat controller reference @@ -42,7 +42,7 @@ Chat controller reference > **get** **initialized**(): `boolean` -Defined in: [src/api.ts:330](../src/api.ts#L330) +Defined in: [src/api.ts:329](../src/api.ts#L329) Chat controller is initialized @@ -58,7 +58,7 @@ Chat controller is initialized > **get** **started**(): `boolean` -Defined in: [src/api.ts:337](../src/api.ts#L337) +Defined in: [src/api.ts:336](../src/api.ts#L336) Chat controller is started @@ -72,7 +72,7 @@ Chat controller is started > **apiAcceptContactRequest**(`contactReqId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:750](../src/api.ts#L750) +Defined in: [src/api.ts:770](../src/api.ts#L770) Accept contact request. Network usage: interactive. @@ -93,7 +93,7 @@ Network usage: interactive. > **apiAcceptMember**(`groupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:570](../src/api.ts#L570) +Defined in: [src/api.ts:590](../src/api.ts#L590) Accept group member. Requires Admin role. Network usage: background. @@ -122,7 +122,7 @@ Network usage: background. > **apiAddMember**(`groupId`, `contactId`, `memberRole`): `Promise`\<`GroupMember`\> -Defined in: [src/api.ts:550](../src/api.ts#L550) +Defined in: [src/api.ts:560](../src/api.ts#L560) Add contact to group. Requires bot to have Admin role. Network usage: interactive. @@ -147,11 +147,44 @@ Network usage: interactive. *** +### apiAddMemberViaGroup() + +> **apiAddMemberViaGroup**(`groupId`, `viaGroupId`, `groupMemberId`, `memberRole`): `Promise`\<`GroupMember`\> + +Defined in: [src/api.ts:570](../src/api.ts#L570) + +Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +Network usage: interactive. + +#### Parameters + +##### groupId + +`number` + +##### viaGroupId + +`number` + +##### groupMemberId + +`number` + +##### memberRole + +`GroupMemberRole` + +#### Returns + +`Promise`\<`GroupMember`\> + +*** + ### apiBlockMembersForAll() > **apiBlockMembersForAll**(`groupId`, `groupMemberIds`, `blocked`): `Promise`\<`void`\> -Defined in: [src/api.ts:590](../src/api.ts#L590) +Defined in: [src/api.ts:610](../src/api.ts#L610) Block members. Requires Moderator role. Network usage: background. @@ -180,7 +213,7 @@ Network usage: background. > **apiCancelFile**(`fileId`): `Promise`\<`void`\> -Defined in: [src/api.ts:540](../src/api.ts#L540) +Defined in: [src/api.ts:550](../src/api.ts#L550) Cancel file. Network usage: background. @@ -201,7 +234,7 @@ Network usage: background. > **apiChatItemReaction**(`chatType`, `chatId`, `chatItemId`, `add`, `reaction`): `Promise`\<`ACIReaction`\> -Defined in: [src/api.ts:513](../src/api.ts#L513) +Defined in: [src/api.ts:523](../src/api.ts#L523) Add/remove message reaction. Network usage: background. @@ -238,7 +271,7 @@ Network usage: background. > **apiConnect**(`userId`, `incognito`, `preparedLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:719](../src/api.ts#L719) +Defined in: [src/api.ts:739](../src/api.ts#L739) Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link Network usage: interactive. @@ -267,7 +300,7 @@ Network usage: interactive. > **apiConnectActiveUser**(`connLink`): `Promise`\<[`ConnReqType`](api.Enumeration.ConnReqType.md)\> -Defined in: [src/api.ts:728](../src/api.ts#L728) +Defined in: [src/api.ts:748](../src/api.ts#L748) Connect via SimpleX link as string in the active user profile. Network usage: interactive. @@ -288,7 +321,7 @@ Network usage: interactive. > **apiConnectPlan**(`userId`, `connectionLink`): `Promise`\<\[`ConnectionPlan`, `CreatedConnLink`\]\> -Defined in: [src/api.ts:709](../src/api.ts#L709) +Defined in: [src/api.ts:729](../src/api.ts#L729) Determine SimpleX link type and if the bot is already connected via this link. Network usage: interactive. @@ -313,7 +346,7 @@ Network usage: interactive. > **apiCreateActiveUser**(`profile?`): `Promise`\<`User`\> -Defined in: [src/api.ts:887](../src/api.ts#L887) +Defined in: [src/api.ts:907](../src/api.ts#L907) Create new user profile Network usage: no. @@ -334,7 +367,7 @@ Network usage: no. > **apiCreateGroupLink**(`groupId`, `memberRole`): `Promise`\<`string`\> -Defined in: [src/api.ts:650](../src/api.ts#L650) +Defined in: [src/api.ts:670](../src/api.ts#L670) Create group link. Network usage: interactive. @@ -359,7 +392,7 @@ Network usage: interactive. > **apiCreateLink**(`userId`): `Promise`\<`string`\> -Defined in: [src/api.ts:696](../src/api.ts#L696) +Defined in: [src/api.ts:716](../src/api.ts#L716) Create 1-time invitation link. Network usage: interactive. @@ -380,7 +413,7 @@ Network usage: interactive. > **apiCreateMemberContact**(`groupId`, `groupMemberId`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:953](../src/api.ts#L953) +Defined in: [src/api.ts:973](../src/api.ts#L973) Create a direct message contact with a group member. Returns the created contact. @@ -406,7 +439,7 @@ Network usage: interactive. > **apiCreateUserAddress**(`userId`): `Promise`\<`CreatedConnLink`\> -Defined in: [src/api.ts:361](../src/api.ts#L361) +Defined in: [src/api.ts:360](../src/api.ts#L360) Create bot address. Network usage: interactive. @@ -427,7 +460,7 @@ Network usage: interactive. > **apiDeleteChat**(`chatType`, `chatId`, `deleteMode?`): `Promise`\<`void`\> -Defined in: [src/api.ts:809](../src/api.ts#L809) +Defined in: [src/api.ts:829](../src/api.ts#L829) Delete chat. Network usage: background. @@ -456,7 +489,7 @@ Network usage: background. > **apiDeleteChatItems**(`chatType`, `chatId`, `chatItemIds`, `deleteMode`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:488](../src/api.ts#L488) +Defined in: [src/api.ts:498](../src/api.ts#L498) Delete message. Network usage: background. @@ -489,7 +522,7 @@ Network usage: background. > **apiDeleteGroupLink**(`groupId`): `Promise`\<`void`\> -Defined in: [src/api.ts:672](../src/api.ts#L672) +Defined in: [src/api.ts:692](../src/api.ts#L692) Delete group link. Network usage: background. @@ -510,7 +543,7 @@ Network usage: background. > **apiDeleteMemberChatItem**(`groupId`, `chatItemIds`): `Promise`\<`ChatItemDeletion`[]\> -Defined in: [src/api.ts:503](../src/api.ts#L503) +Defined in: [src/api.ts:513](../src/api.ts#L513) Moderate message. Requires Moderator role (and higher than message author's). Network usage: background. @@ -535,7 +568,7 @@ Network usage: background. > **apiDeleteUser**(`userId`, `delSMPQueues`, `viewPwd?`): `Promise`\<`void`\> -Defined in: [src/api.ts:917](../src/api.ts#L917) +Defined in: [src/api.ts:937](../src/api.ts#L937) Delete user profile. Network usage: background. @@ -564,7 +597,7 @@ Network usage: background. > **apiDeleteUserAddress**(`userId`): `Promise`\<`void`\> -Defined in: [src/api.ts:371](../src/api.ts#L371) +Defined in: [src/api.ts:370](../src/api.ts#L370) Deletes a user address. Network usage: background. @@ -585,7 +618,7 @@ Network usage: background. > **apiGetActiveUser**(): `Promise`\<`User` \| `undefined`\> -Defined in: [src/api.ts:867](../src/api.ts#L867) +Defined in: [src/api.ts:887](../src/api.ts#L887) Get active user profile Network usage: no. @@ -600,7 +633,7 @@ Network usage: no. > **apiGetChat**(`chatType`, `chatId`, `count`): `Promise`\<`any`\> -Defined in: [src/api.ts:857](../src/api.ts#L857) +Defined in: [src/api.ts:877](../src/api.ts#L877) Get chat items. Network usage: no. @@ -629,7 +662,7 @@ Network usage: no. > **apiGetChats**(`userId`, `pagination`, `query?`, `pendingConnections?`): `Promise`\<`AChat`[]\> -Defined in: [src/api.ts:794](../src/api.ts#L794) +Defined in: [src/api.ts:814](../src/api.ts#L814) Get chat previews (paginated). Network usage: no. @@ -666,7 +699,7 @@ on large databases. > **apiGetGroupLink**(`groupId`): `Promise`\<`GroupLink`\> -Defined in: [src/api.ts:681](../src/api.ts#L681) +Defined in: [src/api.ts:701](../src/api.ts#L701) Get group link. Network usage: no. @@ -687,7 +720,7 @@ Network usage: no. > **apiGetGroupLinkStr**(`groupId`): `Promise`\<`string`\> -Defined in: [src/api.ts:687](../src/api.ts#L687) +Defined in: [src/api.ts:707](../src/api.ts#L707) #### Parameters @@ -705,7 +738,7 @@ Defined in: [src/api.ts:687](../src/api.ts#L687) > **apiGetUserAddress**(`userId`): `Promise`\<`UserContactLink` \| `undefined`\> -Defined in: [src/api.ts:381](../src/api.ts#L381) +Defined in: [src/api.ts:380](../src/api.ts#L380) Get bot address and settings. Network usage: no. @@ -726,7 +759,7 @@ Network usage: no. > **apiJoinGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:560](../src/api.ts#L560) +Defined in: [src/api.ts:580](../src/api.ts#L580) Join group. Network usage: interactive. @@ -747,7 +780,7 @@ Network usage: interactive. > **apiLeaveGroup**(`groupId`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:610](../src/api.ts#L610) +Defined in: [src/api.ts:630](../src/api.ts#L630) Leave group. Network usage: background. @@ -768,7 +801,7 @@ Network usage: background. > **apiListContacts**(`userId`): `Promise`\<`Contact`[]\> -Defined in: [src/api.ts:770](../src/api.ts#L770) +Defined in: [src/api.ts:790](../src/api.ts#L790) Get contacts. Network usage: no. @@ -789,7 +822,7 @@ Network usage: no. > **apiListGroups**(`userId`, `contactId?`, `search?`): `Promise`\<`GroupInfo`[]\> -Defined in: [src/api.ts:780](../src/api.ts#L780) +Defined in: [src/api.ts:800](../src/api.ts#L800) Get groups. Network usage: no. @@ -818,7 +851,7 @@ Network usage: no. > **apiListMembers**(`groupId`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:620](../src/api.ts#L620) +Defined in: [src/api.ts:640](../src/api.ts#L640) Get group members. Network usage: no. @@ -839,7 +872,7 @@ Network usage: no. > **apiListUsers**(): `Promise`\<`UserInfo`[]\> -Defined in: [src/api.ts:897](../src/api.ts#L897) +Defined in: [src/api.ts:917](../src/api.ts#L917) Get all user profiles Network usage: no. @@ -854,7 +887,7 @@ Network usage: no. > **apiNewGroup**(`userId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:630](../src/api.ts#L630) +Defined in: [src/api.ts:650](../src/api.ts#L650) Create group. Network usage: no. @@ -879,7 +912,7 @@ Network usage: no. > **apiReceiveFile**(`fileId`): `Promise`\<`AChatItem`\> -Defined in: [src/api.ts:529](../src/api.ts#L529) +Defined in: [src/api.ts:539](../src/api.ts#L539) Receive file. Network usage: no. @@ -900,7 +933,7 @@ Network usage: no. > **apiRejectContactRequest**(`contactReqId`): `Promise`\<`void`\> -Defined in: [src/api.ts:760](../src/api.ts#L760) +Defined in: [src/api.ts:780](../src/api.ts#L780) Reject contact request. The user who sent the request is **not notified**. Network usage: no. @@ -921,7 +954,7 @@ Network usage: no. > **apiRemoveMembers**(`groupId`, `memberIds`, `withMessages?`): `Promise`\<`GroupMember`[]\> -Defined in: [src/api.ts:600](../src/api.ts#L600) +Defined in: [src/api.ts:620](../src/api.ts#L620) Remove members. Requires Admin role. Network usage: background. @@ -950,7 +983,7 @@ Network usage: background. > **apiSendMemberContactInvitation**(`contactId`, `message?`): `Promise`\<`Contact`\> -Defined in: [src/api.ts:964](../src/api.ts#L964) +Defined in: [src/api.ts:984](../src/api.ts#L984) Send a direct message invitation to a group member contact. The contact must have been created with [apiCreateMemberContact](#apicreatemembercontact). @@ -976,7 +1009,7 @@ Network usage: interactive. > **apiSendMessages**(`chat`, `messages`, `liveMessage?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:432](../src/api.ts#L432) +Defined in: [src/api.ts:442](../src/api.ts#L442) Send messages. Network usage: background. @@ -1005,7 +1038,7 @@ Network usage: background. > **apiSendTextMessage**(`chat`, `text`, `inReplyTo?`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:455](../src/api.ts#L455) +Defined in: [src/api.ts:465](../src/api.ts#L465) Send text message. Network usage: background. @@ -1034,7 +1067,7 @@ Network usage: background. > **apiSendTextReply**(`chatItem`, `text`): `Promise`\<`AChatItem`[]\> -Defined in: [src/api.ts:463](../src/api.ts#L463) +Defined in: [src/api.ts:473](../src/api.ts#L473) Send text message in reply to received message. Network usage: background. @@ -1059,7 +1092,7 @@ Network usage: background. > **apiSetActiveUser**(`userId`, `viewPwd?`): `Promise`\<`User`\> -Defined in: [src/api.ts:907](../src/api.ts#L907) +Defined in: [src/api.ts:927](../src/api.ts#L927) Set active user profile Network usage: no. @@ -1084,7 +1117,7 @@ Network usage: no. > **apiSetAddressSettings**(`userId`, `__namedParameters`): `Promise`\<`void`\> -Defined in: [src/api.ts:415](../src/api.ts#L415) +Defined in: [src/api.ts:414](../src/api.ts#L414) Set bot address settings. Network usage: interactive. @@ -1109,7 +1142,7 @@ Network usage: interactive. > **apiSetAutoAcceptMemberContacts**(`userId`, `onOff`): `Promise`\<`void`\> -Defined in: [src/api.ts:846](../src/api.ts#L846) +Defined in: [src/api.ts:866](../src/api.ts#L866) Set auto-accept member contacts. Network usage: no. @@ -1134,7 +1167,7 @@ Network usage: no. > **apiSetContactCustomData**(`contactId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:836](../src/api.ts#L836) +Defined in: [src/api.ts:856](../src/api.ts#L856) Set contact custom data. Network usage: no. @@ -1159,7 +1192,7 @@ Network usage: no. > **apiSetContactPrefs**(`contactId`, `preferences`): `Promise`\<`void`\> -Defined in: [src/api.ts:943](../src/api.ts#L943) +Defined in: [src/api.ts:963](../src/api.ts#L963) Configure chat preference overrides for the contact. Network usage: background. @@ -1184,7 +1217,7 @@ Network usage: background. > **apiSetGroupCustomData**(`groupId`, `customData?`): `Promise`\<`void`\> -Defined in: [src/api.ts:826](../src/api.ts#L826) +Defined in: [src/api.ts:846](../src/api.ts#L846) Set group custom data. Network usage: no. @@ -1209,7 +1242,7 @@ Network usage: no. > **apiSetGroupLinkMemberRole**(`groupId`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:663](../src/api.ts#L663) +Defined in: [src/api.ts:683](../src/api.ts#L683) Set member role for group link. Network usage: no. @@ -1234,7 +1267,7 @@ Network usage: no. > **apiSetMembersRole**(`groupId`, `groupMemberIds`, `memberRole`): `Promise`\<`void`\> -Defined in: [src/api.ts:580](../src/api.ts#L580) +Defined in: [src/api.ts:600](../src/api.ts#L600) Set members role. Requires Admin role. Network usage: background. @@ -1263,7 +1296,7 @@ Network usage: background. > **apiSetProfileAddress**(`userId`, `enable`): `Promise`\<`UserProfileUpdateSummary`\> -Defined in: [src/api.ts:399](../src/api.ts#L399) +Defined in: [src/api.ts:398](../src/api.ts#L398) Add address to bot profile. Network usage: interactive. @@ -1284,11 +1317,33 @@ Network usage: interactive. *** +### apiSetUserDomain() + +> **apiSetUserDomain**(`userId`, `simplexDomain?`): `Promise`\<`User`\> + +Defined in: [src/api.ts:427](../src/api.ts#L427) + +#### Parameters + +##### userId + +`number` + +##### simplexDomain? + +`string` + +#### Returns + +`Promise`\<`User`\> + +*** + ### apiUpdateChatItem() > **apiUpdateChatItem**(`chatType`, `chatId`, `chatItemId`, `msgContent`, `liveMessage`): `Promise`\<`ChatItem`\> -Defined in: [src/api.ts:471](../src/api.ts#L471) +Defined in: [src/api.ts:481](../src/api.ts#L481) Update message. Network usage: background. @@ -1325,7 +1380,7 @@ Network usage: background. > **apiUpdateGroupProfile**(`groupId`, `groupProfile`): `Promise`\<`GroupInfo`\> -Defined in: [src/api.ts:640](../src/api.ts#L640) +Defined in: [src/api.ts:660](../src/api.ts#L660) Update group profile. Network usage: background. @@ -1350,7 +1405,7 @@ Network usage: background. > **apiUpdateProfile**(`userId`, `profile`): `Promise`\<`UserProfileUpdateSummary` \| `undefined`\> -Defined in: [src/api.ts:927](../src/api.ts#L927) +Defined in: [src/api.ts:947](../src/api.ts#L947) Update user profile. Network usage: background. @@ -1375,7 +1430,7 @@ Network usage: background. > **close**(): `Promise`\<`void`\> -Defined in: [src/api.ts:158](../src/api.ts#L158) +Defined in: [src/api.ts:157](../src/api.ts#L157) Stop chat controller and close chat database. The database is not closed if stopping fails. @@ -1391,7 +1446,7 @@ Usually doesn't need to be called in chat bots. > **off**\<`K`\>(`event`, `subscriber?`): `void` -Defined in: [src/api.ts:302](../src/api.ts#L302) +Defined in: [src/api.ts:301](../src/api.ts#L301) Unsubscribe all or a specific handler from a specific event. @@ -1425,7 +1480,7 @@ An optional subscriber function for the event. > **offAny**(`receiver?`): `void` -Defined in: [src/api.ts:318](../src/api.ts#L318) +Defined in: [src/api.ts:317](../src/api.ts#L317) Unsubscribe all or a specific handler from any events. @@ -1449,7 +1504,7 @@ An optional subscriber function for the event. > **on**\<`K`\>(`subscribers`): `void` -Defined in: [src/api.ts:212](../src/api.ts#L212) +Defined in: [src/api.ts:211](../src/api.ts#L211) Subscribe multiple event handlers at once. @@ -1479,7 +1534,7 @@ If the same function is subscribed to event. > **on**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:220](../src/api.ts#L220) +Defined in: [src/api.ts:219](../src/api.ts#L219) Subscribe a handler to a specific event. @@ -1517,7 +1572,7 @@ If the same function is subscribed to event. > **onAny**(`receiver`): `void` -Defined in: [src/api.ts:243](../src/api.ts#L243) +Defined in: [src/api.ts:242](../src/api.ts#L242) Subscribe a handler to any event. @@ -1543,7 +1598,7 @@ If the same function is subscribed to event. > **once**\<`K`\>(`event`, `subscriber`): `void` -Defined in: [src/api.ts:254](../src/api.ts#L254) +Defined in: [src/api.ts:253](../src/api.ts#L253) Subscribe a handler to a specific event to be delivered one time. @@ -1581,7 +1636,7 @@ If the same function is subscribed to event. > **recvChatEvent**(`wait?`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/api.ts:353](../src/api.ts#L353) +Defined in: [src/api.ts:352](../src/api.ts#L352) #### Parameters @@ -1599,7 +1654,7 @@ Defined in: [src/api.ts:353](../src/api.ts#L353) > **sendChatCmd**(`cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/api.ts:349](../src/api.ts#L349) +Defined in: [src/api.ts:348](../src/api.ts#L348) #### Parameters @@ -1617,7 +1672,7 @@ Defined in: [src/api.ts:349](../src/api.ts#L349) > **startChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:124](../src/api.ts#L124) +Defined in: [src/api.ts:123](../src/api.ts#L123) Start chat controller. Must be called with the existing user profile. @@ -1631,7 +1686,7 @@ Start chat controller. Must be called with the existing user profile. > **stopChat**(): `Promise`\<`void`\> -Defined in: [src/api.ts:147](../src/api.ts#L147) +Defined in: [src/api.ts:146](../src/api.ts#L146) Stop chat controller. `close` calls it before closing the database. @@ -1649,7 +1704,7 @@ Usually doesn't need to be called in chat bots. > **wait**\<`K`\>(`event`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:262](../src/api.ts#L262) +Defined in: [src/api.ts:261](../src/api.ts#L261) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1674,7 +1729,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`): `Promise`\<`ChatEvent` & `object`\> -Defined in: [src/api.ts:263](../src/api.ts#L263) +Defined in: [src/api.ts:262](../src/api.ts#L262) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1703,7 +1758,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `timeout`): `Promise`\ -Defined in: [src/api.ts:264](../src/api.ts#L264) +Defined in: [src/api.ts:263](../src/api.ts#L263) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1732,7 +1787,7 @@ Returns `undefined` on timeout if specified. > **wait**\<`K`\>(`event`, `predicate`, `timeout`): `Promise`\ -Defined in: [src/api.ts:265](../src/api.ts#L265) +Defined in: [src/api.ts:264](../src/api.ts#L264) Waits for specific event, with an optional predicate. Returns `undefined` on timeout if specified. @@ -1767,9 +1822,9 @@ Returns `undefined` on timeout if specified. > `static` **init**(`db`, `confirm?`, `queueSize?`): `Promise`\<`ChatApi`\> -Defined in: [src/api.ts:111](../src/api.ts#L111) +Defined in: [src/api.ts:109](../src/api.ts#L109) -Initializes the ChatApi. +Initializes the ChatApi, loading libsimplex for `db.type` (downloaded on first use). #### Parameters diff --git a/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md b/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md index 7fe255327c..26934a9c6b 100644 --- a/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md +++ b/packages/simplex-chat-nodejs/docs/api.TypeAlias.DbConfig.md @@ -8,12 +8,10 @@ > **DbConfig** = \{ `encryptionKey?`: `string`; `filePrefix`: `string`; `type`: `"sqlite"`; \} \| \{ `connectionString`: `string`; `schemaPrefix?`: `string`; `type`: `"postgres"`; \} -Defined in: [src/api.ts:65](../src/api.ts#L65) +Defined in: [src/api.ts:63](../src/api.ts#L63) -Database configuration. The native library is built against exactly one -backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this -type makes the caller state which one they are targeting so field names -can't lie about their meaning. +Database configuration. `type` selects the libsimplex backend loaded by +`ChatApi.init`; one backend per process. ## Union Members diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.run.md b/packages/simplex-chat-nodejs/docs/bot.Function.run.md index 0af2644bc2..bc31ad01a8 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.run.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.run.md @@ -8,7 +8,7 @@ > **run**(`__namedParameters`): `Promise`\<\[[`ChatApi`](api.Class.ChatApi.md), `User`, `UserContactLink` \| `undefined`\]\> -Defined in: [src/bot.ts:48](../src/bot.ts#L48) +Defined in: [src/bot.ts:49](../src/bot.ts#L49) ## Parameters diff --git a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md index 8fbb699f80..0bcc49cbc6 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md +++ b/packages/simplex-chat-nodejs/docs/bot.Function.subscribeChatItems.md @@ -8,7 +8,7 @@ > **subscribeChatItems**(`bot`, `onMessage`, `commands`): `void` -Defined in: [src/bot.ts:108](../src/bot.ts#L108) +Defined in: [src/bot.ts:110](../src/bot.ts#L110) ## Parameters diff --git a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md index 3f7757041b..036afe64d4 100644 --- a/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md +++ b/packages/simplex-chat-nodejs/docs/bot.Interface.BotConfig.md @@ -14,7 +14,7 @@ Defined in: [src/bot.ts:36](../src/bot.ts#L36) > **dbOpts**: [`BotDbOpts`](bot.TypeAlias.BotDbOpts.md) -Defined in: [src/bot.ts:38](../src/bot.ts#L38) +Defined in: [src/bot.ts:39](../src/bot.ts#L39) *** @@ -22,7 +22,7 @@ Defined in: [src/bot.ts:38](../src/bot.ts#L38) > `optional` **events?**: [`EventSubscribers`](api.TypeAlias.EventSubscribers.md) -Defined in: [src/bot.ts:45](../src/bot.ts#L45) +Defined in: [src/bot.ts:46](../src/bot.ts#L46) *** @@ -30,7 +30,7 @@ Defined in: [src/bot.ts:45](../src/bot.ts#L45) > `optional` **onCommands?**: `object` -Defined in: [src/bot.ts:42](../src/bot.ts#L42) +Defined in: [src/bot.ts:43](../src/bot.ts#L43) #### Index Signature @@ -42,7 +42,7 @@ Defined in: [src/bot.ts:42](../src/bot.ts#L42) > `optional` **onMessage?**: (`chatItem`, `content`, `chat`) => `void` \| `Promise`\<`void`\> -Defined in: [src/bot.ts:40](../src/bot.ts#L40) +Defined in: [src/bot.ts:41](../src/bot.ts#L41) #### Parameters @@ -68,7 +68,7 @@ Defined in: [src/bot.ts:40](../src/bot.ts#L40) > **options**: [`BotOptions`](bot.Interface.BotOptions.md) -Defined in: [src/bot.ts:39](../src/bot.ts#L39) +Defined in: [src/bot.ts:40](../src/bot.ts#L40) *** @@ -77,3 +77,11 @@ Defined in: [src/bot.ts:39](../src/bot.ts#L39) > **profile**: `Profile` Defined in: [src/bot.ts:37](../src/bot.ts#L37) + +*** + +### simplexDomain? + +> `optional` **simplexDomain?**: `string` \| `null` + +Defined in: [src/bot.ts:38](../src/bot.ts#L38) diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md index 3953d777da..64e3e764df 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatAPIError.md @@ -6,7 +6,7 @@ # Class: ChatAPIError -Defined in: [src/core.ts:95](../src/core.ts#L95) +Defined in: [src/core.ts:117](../src/core.ts#L117) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:95](../src/core.ts#L95) > **new ChatAPIError**(`message`, `chatError?`): `ChatAPIError` -Defined in: [src/core.ts:96](../src/core.ts#L96) +Defined in: [src/core.ts:118](../src/core.ts#L118) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:96](../src/core.ts#L96) > **chatError**: `ChatError` \| `undefined` = `undefined` -Defined in: [src/core.ts:96](../src/core.ts#L96) +Defined in: [src/core.ts:118](../src/core.ts#L118) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:96](../src/core.ts#L96) > **message**: `string` -Defined in: [src/core.ts:96](../src/core.ts#L96) +Defined in: [src/core.ts:118](../src/core.ts#L118) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md index 649090e3ac..aefc4b9073 100644 --- a/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md +++ b/packages/simplex-chat-nodejs/docs/core.Class.ChatInitError.md @@ -6,7 +6,7 @@ # Class: ChatInitError -Defined in: [src/core.ts:119](../src/core.ts#L119) +Defined in: [src/core.ts:141](../src/core.ts#L141) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:119](../src/core.ts#L119) > **new ChatInitError**(`message`, `dbMigrationError`): `ChatInitError` -Defined in: [src/core.ts:120](../src/core.ts#L120) +Defined in: [src/core.ts:142](../src/core.ts#L142) #### Parameters @@ -44,7 +44,7 @@ Defined in: [src/core.ts:120](../src/core.ts#L120) > **dbMigrationError**: [`DBMigrationError`](core.TypeAlias.DBMigrationError.md) -Defined in: [src/core.ts:120](../src/core.ts#L120) +Defined in: [src/core.ts:142](../src/core.ts#L142) *** @@ -52,7 +52,7 @@ Defined in: [src/core.ts:120](../src/core.ts#L120) > **message**: `string` -Defined in: [src/core.ts:120](../src/core.ts#L120) +Defined in: [src/core.ts:142](../src/core.ts#L142) #### Inherited from diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md index fea7bfb531..9b6ad3c099 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorMigration.md @@ -6,7 +6,7 @@ # Interface: ErrorMigration -Defined in: [src/core.ts:152](../src/core.ts#L152) +Defined in: [src/core.ts:174](../src/core.ts#L174) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:152](../src/core.ts#L152) > **dbFile**: `string` -Defined in: [src/core.ts:154](../src/core.ts#L154) +Defined in: [src/core.ts:176](../src/core.ts#L176) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:154](../src/core.ts#L154) > **migrationError**: [`MigrationError`](core.TypeAlias.MigrationError.md) -Defined in: [src/core.ts:155](../src/core.ts#L155) +Defined in: [src/core.ts:177](../src/core.ts#L177) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:155](../src/core.ts#L155) > **type**: `"errorMigration"` -Defined in: [src/core.ts:153](../src/core.ts#L153) +Defined in: [src/core.ts:175](../src/core.ts#L175) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md index eb0bfb11cb..d54f59003f 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorNotADatabase.md @@ -6,7 +6,7 @@ # Interface: ErrorNotADatabase -Defined in: [src/core.ts:147](../src/core.ts#L147) +Defined in: [src/core.ts:169](../src/core.ts#L169) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:147](../src/core.ts#L147) > **dbFile**: `string` -Defined in: [src/core.ts:149](../src/core.ts#L149) +Defined in: [src/core.ts:171](../src/core.ts#L171) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:149](../src/core.ts#L149) > **type**: `"errorNotADatabase"` -Defined in: [src/core.ts:148](../src/core.ts#L148) +Defined in: [src/core.ts:170](../src/core.ts#L170) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md index ffaa364f9b..e76de932f6 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.ErrorSQL.md @@ -6,7 +6,7 @@ # Interface: ErrorSQL -Defined in: [src/core.ts:158](../src/core.ts#L158) +Defined in: [src/core.ts:180](../src/core.ts#L180) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:158](../src/core.ts#L158) > **dbFile**: `string` -Defined in: [src/core.ts:160](../src/core.ts#L160) +Defined in: [src/core.ts:182](../src/core.ts#L182) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:160](../src/core.ts#L160) > **migrationSQLError**: `string` -Defined in: [src/core.ts:161](../src/core.ts#L161) +Defined in: [src/core.ts:183](../src/core.ts#L183) *** @@ -34,7 +34,7 @@ Defined in: [src/core.ts:161](../src/core.ts#L161) > **type**: `"errorSQL"` -Defined in: [src/core.ts:159](../src/core.ts#L159) +Defined in: [src/core.ts:181](../src/core.ts#L181) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md index 67711030da..30157c808d 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidConfirmation.md @@ -6,7 +6,7 @@ # Interface: InvalidConfirmation -Defined in: [src/core.ts:139](../src/core.ts#L139) +Defined in: [src/core.ts:161](../src/core.ts#L161) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:139](../src/core.ts#L139) > **type**: `"invalidConfirmation"` -Defined in: [src/core.ts:140](../src/core.ts#L140) +Defined in: [src/core.ts:162](../src/core.ts#L162) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md index 9093b5e9b1..5264e4977f 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.Interface.InvalidQueueSize.md @@ -6,7 +6,7 @@ # Interface: InvalidQueueSize -Defined in: [src/core.ts:143](../src/core.ts#L143) +Defined in: [src/core.ts:165](../src/core.ts#L165) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:143](../src/core.ts#L143) > **type**: `"invalidQueueSize"` -Defined in: [src/core.ts:144](../src/core.ts#L144) +Defined in: [src/core.ts:166](../src/core.ts#L166) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md index b04f2f5af1..d94d5893ad 100644 --- a/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.DBMigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"invalidConfirmation"` \| `"invalidQueueSize"` \| `"errorNotADatabase"` \| `"errorMigration"` \| `"errorSQL"` -Defined in: [src/core.ts:133](../src/core.ts#L133) +Defined in: [src/core.ts:155](../src/core.ts#L155) diff --git a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md index 48e250c7d4..cc2dea3901 100644 --- a/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md +++ b/packages/simplex-chat-nodejs/docs/core.Enumeration.MigrationConfirmation.md @@ -6,7 +6,7 @@ # Enumeration: MigrationConfirmation -Defined in: [src/core.ts:104](../src/core.ts#L104) +Defined in: [src/core.ts:126](../src/core.ts#L126) Migration confirmation mode @@ -16,7 +16,7 @@ Migration confirmation mode > **Console**: `"console"` -Defined in: [src/core.ts:107](../src/core.ts#L107) +Defined in: [src/core.ts:129](../src/core.ts#L129) *** @@ -24,7 +24,7 @@ Defined in: [src/core.ts:107](../src/core.ts#L107) > **Error**: `"error"` -Defined in: [src/core.ts:108](../src/core.ts#L108) +Defined in: [src/core.ts:130](../src/core.ts#L130) *** @@ -32,7 +32,7 @@ Defined in: [src/core.ts:108](../src/core.ts#L108) > **YesUp**: `"yesUp"` -Defined in: [src/core.ts:105](../src/core.ts#L105) +Defined in: [src/core.ts:127](../src/core.ts#L127) *** @@ -40,4 +40,4 @@ Defined in: [src/core.ts:105](../src/core.ts#L105) > **YesUpDown**: `"yesUpDown"` -Defined in: [src/core.ts:106](../src/core.ts#L106) +Defined in: [src/core.ts:128](../src/core.ts#L128) diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md index 4b0324b92c..69be845ed3 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatCloseStore.md @@ -8,7 +8,7 @@ > **chatCloseStore**(`ctrl`): `Promise`\<`void`\> -Defined in: [src/core.ts:20](../src/core.ts#L20) +Defined in: [src/core.ts:42](../src/core.ts#L42) Close chat store diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md index b6786fe1cb..a2a901ddc5 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatDecryptFile.md @@ -8,7 +8,7 @@ > **chatDecryptFile**(`fromPath`, `__namedParameters`, `toPath`): `Promise`\<`void`\> -Defined in: [src/core.ts:76](../src/core.ts#L76) +Defined in: [src/core.ts:98](../src/core.ts#L98) Decrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md index 9e30e55a23..8f4c7815c4 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatEncryptFile.md @@ -8,7 +8,7 @@ > **chatEncryptFile**(`ctrl`, `fromPath`, `toPath`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:68](../src/core.ts#L68) +Defined in: [src/core.ts:90](../src/core.ts#L90) Encrypt file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md index dbe4912520..c6ad146aa1 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatMigrateInit.md @@ -8,7 +8,7 @@ > **chatMigrateInit**(`dbPath`, `dbKey`, `confirm`, `queueSize?`): `Promise`\<`bigint`\> -Defined in: [src/core.ts:8](../src/core.ts#L8) +Defined in: [src/core.ts:30](../src/core.ts#L30) Initialize chat controller diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md index f6713f22d8..f9413c4d74 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatReadFile.md @@ -8,7 +8,7 @@ > **chatReadFile**(`path`, `__namedParameters`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\> -Defined in: [src/core.ts:61](../src/core.ts#L61) +Defined in: [src/core.ts:83](../src/core.ts#L83) Read buffer from encrypted file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md index 719cf610a1..8aaa2e1590 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatRecvMsgWait.md @@ -8,7 +8,7 @@ > **chatRecvMsgWait**(`ctrl`, `wait`): `Promise`\<`ChatEvent` \| `undefined`\> -Defined in: [src/core.ts:40](../src/core.ts#L40) +Defined in: [src/core.ts:62](../src/core.ts#L62) Receive chat event diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md index 4296a714cf..9f8c34be0f 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatSendCmd.md @@ -8,7 +8,7 @@ > **chatSendCmd**(`ctrl`, `cmd`): `Promise`\<`ChatResponse`\> -Defined in: [src/core.ts:28](../src/core.ts#L28) +Defined in: [src/core.ts:50](../src/core.ts#L50) Send chat command as string diff --git a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md index 4ca640d8c4..bd22e9d2c6 100644 --- a/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md +++ b/packages/simplex-chat-nodejs/docs/core.Function.chatWriteFile.md @@ -8,7 +8,7 @@ > **chatWriteFile**(`ctrl`, `path`, `buffer`): `Promise`\<[`CryptoArgs`](core.Interface.CryptoArgs.md)\> -Defined in: [src/core.ts:53](../src/core.ts#L53) +Defined in: [src/core.ts:75](../src/core.ts#L75) Write buffer to encrypted file diff --git a/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md b/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md new file mode 100644 index 0000000000..a2034b953d --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.Function.loadLibrary.md @@ -0,0 +1,24 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / loadLibrary + +# Function: loadLibrary() + +> **loadLibrary**(`backend`): `Promise`\<`void`\> + +Defined in: [src/core.ts:13](../src/core.ts#L13) + +Resolve (downloading on first use) and load libsimplex for the backend. +One libsimplex per process: the Haskell runtime is initialized once, and another backend is rejected. + +## Parameters + +### backend + +[`Backend`](core.TypeAlias.Backend.md) + +## Returns + +`Promise`\<`void`\> diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md index 2ede6d9ffb..63b0caba85 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.APIResult.md @@ -6,7 +6,7 @@ # Interface: APIResult\ -Defined in: [src/core.ts:90](../src/core.ts#L90) +Defined in: [src/core.ts:112](../src/core.ts#L112) ## Type Parameters @@ -20,7 +20,7 @@ Defined in: [src/core.ts:90](../src/core.ts#L90) > `optional` **error?**: `ChatError` -Defined in: [src/core.ts:92](../src/core.ts#L92) +Defined in: [src/core.ts:114](../src/core.ts#L114) *** @@ -28,4 +28,4 @@ Defined in: [src/core.ts:92](../src/core.ts#L92) > `optional` **result?**: `R` -Defined in: [src/core.ts:91](../src/core.ts#L91) +Defined in: [src/core.ts:113](../src/core.ts#L113) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md index 51a55b86b6..939b3f7430 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.CryptoArgs.md @@ -6,7 +6,7 @@ # Interface: CryptoArgs -Defined in: [src/core.ts:114](../src/core.ts#L114) +Defined in: [src/core.ts:136](../src/core.ts#L136) File encryption key and nonce @@ -16,7 +16,7 @@ File encryption key and nonce > **fileKey**: `string` -Defined in: [src/core.ts:115](../src/core.ts#L115) +Defined in: [src/core.ts:137](../src/core.ts#L137) *** @@ -24,4 +24,4 @@ Defined in: [src/core.ts:115](../src/core.ts#L115) > **fileNonce**: `string` -Defined in: [src/core.ts:116](../src/core.ts#L116) +Defined in: [src/core.ts:138](../src/core.ts#L138) diff --git a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md index 9e1fca49b4..30a7f0b060 100644 --- a/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md +++ b/packages/simplex-chat-nodejs/docs/core.Interface.UpMigration.md @@ -6,7 +6,7 @@ # Interface: UpMigration -Defined in: [src/core.ts:193](../src/core.ts#L193) +Defined in: [src/core.ts:215](../src/core.ts#L215) ## Properties @@ -14,7 +14,7 @@ Defined in: [src/core.ts:193](../src/core.ts#L193) > **upName**: `string` -Defined in: [src/core.ts:194](../src/core.ts#L194) +Defined in: [src/core.ts:216](../src/core.ts#L216) *** @@ -22,4 +22,4 @@ Defined in: [src/core.ts:194](../src/core.ts#L194) > **withDown**: `boolean` -Defined in: [src/core.ts:195](../src/core.ts#L195) +Defined in: [src/core.ts:217](../src/core.ts#L217) diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md index f85fb492be..aaaa506f1e 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTREDifferent.md @@ -6,7 +6,7 @@ # Interface: MTREDifferent -Defined in: [src/core.ts:214](../src/core.ts#L214) +Defined in: [src/core.ts:236](../src/core.ts#L236) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:214](../src/core.ts#L214) > **downMigrations**: `string`[] -Defined in: [src/core.ts:216](../src/core.ts#L216) +Defined in: [src/core.ts:238](../src/core.ts#L238) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:216](../src/core.ts#L216) > **type**: `"different"` -Defined in: [src/core.ts:215](../src/core.ts#L215) +Defined in: [src/core.ts:237](../src/core.ts#L237) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md index f031ac8982..e088536be9 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.Interface.MTRENoDown.md @@ -6,7 +6,7 @@ # Interface: MTRENoDown -Defined in: [src/core.ts:209](../src/core.ts#L209) +Defined in: [src/core.ts:231](../src/core.ts#L231) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:209](../src/core.ts#L209) > **dbMigrations**: `string`[] -Defined in: [src/core.ts:211](../src/core.ts#L211) +Defined in: [src/core.ts:233](../src/core.ts#L233) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:211](../src/core.ts#L211) > **type**: `"noDown"` -Defined in: [src/core.ts:210](../src/core.ts#L210) +Defined in: [src/core.ts:232](../src/core.ts#L232) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md index 59501b539a..058c64d396 100644 --- a/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MTRError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"noDown"` \| `"different"` -Defined in: [src/core.ts:203](../src/core.ts#L203) +Defined in: [src/core.ts:225](../src/core.ts#L225) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md index f6202b9456..523fe8fe1e 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEDowngrade.md @@ -6,7 +6,7 @@ # Interface: MEDowngrade -Defined in: [src/core.ts:182](../src/core.ts#L182) +Defined in: [src/core.ts:204](../src/core.ts#L204) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:182](../src/core.ts#L182) > **downMigrations**: `string`[] -Defined in: [src/core.ts:184](../src/core.ts#L184) +Defined in: [src/core.ts:206](../src/core.ts#L206) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:184](../src/core.ts#L184) > **type**: `"downgrade"` -Defined in: [src/core.ts:183](../src/core.ts#L183) +Defined in: [src/core.ts:205](../src/core.ts#L205) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md index 7467895b74..c5856f1943 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MEUpgrade.md @@ -6,7 +6,7 @@ # Interface: MEUpgrade -Defined in: [src/core.ts:177](../src/core.ts#L177) +Defined in: [src/core.ts:199](../src/core.ts#L199) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:177](../src/core.ts#L177) > **type**: `"upgrade"` -Defined in: [src/core.ts:178](../src/core.ts#L178) +Defined in: [src/core.ts:200](../src/core.ts#L200) #### Overrides @@ -30,4 +30,4 @@ Defined in: [src/core.ts:178](../src/core.ts#L178) > **upMigrations**: [`UpMigration`](core.Interface.UpMigration.md)[] -Defined in: [src/core.ts:179](../src/core.ts#L179) +Defined in: [src/core.ts:201](../src/core.ts#L201) diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md index 7b2546fcd5..f3f7d9bc1f 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.Interface.MigrationError.md @@ -6,7 +6,7 @@ # Interface: MigrationError -Defined in: [src/core.ts:187](../src/core.ts#L187) +Defined in: [src/core.ts:209](../src/core.ts#L209) ## Extends @@ -18,7 +18,7 @@ Defined in: [src/core.ts:187](../src/core.ts#L187) > **mtrError**: [`MTRError`](core.TypeAlias.MTRError.md) -Defined in: [src/core.ts:189](../src/core.ts#L189) +Defined in: [src/core.ts:211](../src/core.ts#L211) *** @@ -26,7 +26,7 @@ Defined in: [src/core.ts:189](../src/core.ts#L189) > **type**: `"migrationError"` -Defined in: [src/core.ts:188](../src/core.ts#L188) +Defined in: [src/core.ts:210](../src/core.ts#L210) #### Overrides diff --git a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md index e2e1dcb33f..92f649e615 100644 --- a/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md +++ b/packages/simplex-chat-nodejs/docs/core.MigrationError.TypeAlias.Tag.md @@ -8,4 +8,4 @@ > **Tag** = `"upgrade"` \| `"downgrade"` \| `"migrationError"` -Defined in: [src/core.ts:171](../src/core.ts#L171) +Defined in: [src/core.ts:193](../src/core.ts#L193) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md new file mode 100644 index 0000000000..9b32c20d5b --- /dev/null +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.Backend.md @@ -0,0 +1,11 @@ +[**simplex-chat**](README.md) + +*** + +[simplex-chat](README.md) / [core](Namespace.core.md) / Backend + +# Type Alias: Backend + +> **Backend** = `"sqlite"` \| `"postgres"` + +Defined in: [src/core.ts:5](../src/core.ts#L5) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md index 3f4797fb8e..159ce87391 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.DBMigrationError.md @@ -8,4 +8,4 @@ > **DBMigrationError** = [`InvalidConfirmation`](core.DBMigrationError.Interface.InvalidConfirmation.md) \| [`InvalidQueueSize`](core.DBMigrationError.Interface.InvalidQueueSize.md) \| [`ErrorNotADatabase`](core.DBMigrationError.Interface.ErrorNotADatabase.md) \| [`ErrorMigration`](core.DBMigrationError.Interface.ErrorMigration.md) \| [`ErrorSQL`](core.DBMigrationError.Interface.ErrorSQL.md) -Defined in: [src/core.ts:125](../src/core.ts#L125) +Defined in: [src/core.ts:147](../src/core.ts#L147) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md index 66d6a092dc..1c4ceb16e9 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MTRError.md @@ -8,4 +8,4 @@ > **MTRError** = [`MTRENoDown`](core.MTRError.Interface.MTRENoDown.md) \| [`MTREDifferent`](core.MTRError.Interface.MTREDifferent.md) -Defined in: [src/core.ts:198](../src/core.ts#L198) +Defined in: [src/core.ts:220](../src/core.ts#L220) diff --git a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md index 6c0ba77541..485311f0de 100644 --- a/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md +++ b/packages/simplex-chat-nodejs/docs/core.TypeAlias.MigrationError.md @@ -8,4 +8,4 @@ > **MigrationError** = [`MEUpgrade`](core.MigrationError.Interface.MEUpgrade.md) \| [`MEDowngrade`](core.MigrationError.Interface.MEDowngrade.md) \| [`MigrationError`](core.MigrationError.Interface.MigrationError.md) -Defined in: [src/core.ts:165](../src/core.ts#L165) +Defined in: [src/core.ts:187](../src/core.ts#L187) diff --git a/packages/simplex-chat-nodejs/jest.config.js b/packages/simplex-chat-nodejs/jest.config.js index 18c5ddf0e5..ac94fe9001 100644 --- a/packages/simplex-chat-nodejs/jest.config.js +++ b/packages/simplex-chat-nodejs/jest.config.js @@ -1,6 +1,13 @@ +const path = require("path") + +// Tests load the locally built add-on instead of downloading the released one. +process.env.SIMPLEX_ADDON_PATH ??= path.join(__dirname, "build", "Release", "simplex.node") + module.exports = { preset: "ts-jest", maxWorkers: 1, + // a test with a new database runs every migration, each committed with a disk flush: seconds on slow CI runners + testTimeout: 30000, testEnvironment: "node", transform: { '^.+\\.ts$': ['ts-jest', { diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 8a9786d559..eaf9de68fb 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,22 +1,22 @@ { "name": "simplex-chat", - "version": "7.1.0-beta.5", + "version": "7.1.0-beta.6.4", "main": "dist/index.js", "types": "dist/index.d.ts", + "gypfile": false, + "bin": { + "simplex-chat": "dist/cli.js" + }, "files": [ "src", - "cpp", "dist", - "binding.gyp", "tsconfig.json", "docs", "examples" ], "scripts": { - "preinstall": "node src/download-libs.js", - "install": "node-gyp configure; node-gyp rebuild --release", "install-tools": "npm install -g node-gyp", - "configure": "node-gyp configure; mkdir libs 2> /dev/null | true", + "configure": "node-gyp configure", "build": "node-gyp rebuild && tsc && cp ./src/simplex.* ./dist", "run": "node src/index.js", "build-run": "node-gyp build && node src/index.js", @@ -24,15 +24,15 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.11.4", + "@simplex-chat/types": "^7.1.0-beta.6.1", "extract-zip": "^2.0.1", - "fast-deep-equal": "^3.1.3", - "node-addon-api": "^8.5.0" + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@types/jest": "^30.0.0", "@types/node": "^25.0.5", "jest": "^30.2.0", + "node-addon-api": "^8.5.0", "ts-jest": "^29.4.6", "typedoc": "^0.28.15", "typedoc-plugin-markdown": "^4.9.0", diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index 8d4e75b181..4ee4e4b3b9 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -57,10 +57,8 @@ interface EventSubscriber { } /** - * Database configuration. The native library is built against exactly one - * backend (see `simplex_backend` / `SIMPLEX_BACKEND` at install time); this - * type makes the caller state which one they are targeting so field names - * can't lie about their meaning. + * Database configuration. `type` selects the libsimplex backend loaded by + * `ChatApi.init`; one backend per process. */ export type DbConfig = | { @@ -80,6 +78,11 @@ export type DbConfig = connectionString: string } +function chatErrorEvent(err: unknown): ChatEvent { + if (err instanceof core.ChatAPIError && err.chatError) return {type: "chatError", chatError: err.chatError} + throw err +} + function dbConfigToMigrateArgs(db: DbConfig): [string, string] { switch (db.type) { case "sqlite": @@ -103,7 +106,7 @@ export class ChatApi { private constructor(protected ctrl_: bigint | undefined) {} /** - * Initializes the ChatApi. + * Initializes the ChatApi, loading libsimplex for `db.type` (downloaded on first use). * @param {DbConfig} db - Database configuration (sqlite or postgres). * @param {core.MigrationConfirmation} [confirm=core.MigrationConfirmation.YesUp] - Migration confirmation mode. * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. @@ -114,6 +117,7 @@ export class ChatApi { queueSize?: number ): Promise { const [path, key] = dbConfigToMigrateArgs(db) + await core.loadLibrary(db.type) const ctrl = await core.chatMigrateInit(path, key, confirm, queueSize) return new ChatApi(ctrl) } @@ -171,7 +175,7 @@ export class ChatApi { private async runEventsLoop(): Promise { while (this.receiveEvents) { try { - const event = await this.recvChatEvent() + const event = await this.recvChatEvent().catch(chatErrorEvent) if (!event) continue const subs = this.subscribers[event.type] if (subs) { @@ -194,12 +198,7 @@ export class ChatApi { } } } catch(err) { - const e = err as core.ChatAPIError - if ("chatError" in e) { - console.log("Chat error", e.chatError) - } else { - console.log("Invalid event", e) - } + console.log("Invalid event", err) } } } @@ -564,6 +563,16 @@ export class ChatApi { throw new ChatCommandError("error adding member", r) } + /** + * Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. + * Network usage: interactive. + */ + async apiAddMemberViaGroup(groupId: number, viaGroupId: number, groupMemberId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})) + if (r.type === "sentGroupInvitationViaGroup") return r.member + throw new ChatCommandError("error adding member", r) + } + /** * Join group. * Network usage: interactive. diff --git a/packages/simplex-chat-nodejs/src/cli.ts b/packages/simplex-chat-nodejs/src/cli.ts new file mode 100644 index 0000000000..8f24e5a89d --- /dev/null +++ b/packages/simplex-chat-nodejs/src/cli.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import {parseArgs} from "util" +import type {Backend} from "./core" +import {resolveAddonPath, resolveLibsDir} from "./libs" + +const USAGE = "usage: simplex-chat install [--backend sqlite|postgres]" +const EXIT_INSTALL_FAILED = 1 +const EXIT_USAGE = 2 + +export function parseInstallArgs(argv: string[]): Backend { + const {positionals, values: {backend = "sqlite"}} = parseArgs({args: argv, allowPositionals: true, options: {backend: {type: "string"}}}) + if (positionals.length !== 1 || positionals[0] !== "install") throw new Error("expected command: install") + if (backend !== "sqlite" && backend !== "postgres") throw new Error(`invalid backend: ${backend}`) + return backend +} + +export async function main(argv: string[]): Promise { + if (argv.includes("-h") || argv.includes("--help")) { + console.log(USAGE) + return 0 + } + let backend: Backend + try { + backend = parseInstallArgs(argv) + } catch (e) { + console.error(`${(e as Error).message}\n${USAGE}`) + return EXIT_USAGE + } + try { + const [dir, addon] = await Promise.all([resolveLibsDir(backend), resolveAddonPath()]) + console.log(`libsimplex installed at: ${dir}`) + console.log(`Node.js add-on installed at: ${addon}`) + return 0 + } catch (e) { + console.error(`install failed: ${(e as Error).message}`) + return EXIT_INSTALL_FAILED + } +} + +if (require.main === module) { + main(process.argv.slice(2)).then(code => { process.exitCode = code }) +} diff --git a/packages/simplex-chat-nodejs/src/core.ts b/packages/simplex-chat-nodejs/src/core.ts index 49664fed71..5c246cf5c1 100644 --- a/packages/simplex-chat-nodejs/src/core.ts +++ b/packages/simplex-chat-nodejs/src/core.ts @@ -1,6 +1,28 @@ import {ChatEvent, ChatResponse, T} from "@simplex-chat/types" +import * as libs from "./libs" import * as simplex from "./simplex" +export type Backend = "sqlite" | "postgres" + +let loading: {backend: Backend, promise: Promise} | undefined + +/** + * Resolve (downloading on first use) and load libsimplex for the backend. + * One libsimplex per process: the Haskell runtime is initialized once, and another backend is rejected. + */ +export function loadLibrary(backend: Backend): Promise { + if (loading) { + if (loading.backend === backend) return loading.promise + return Promise.reject(new Error(`libsimplex already loaded with backend=${loading.backend}; cannot switch to ${backend} in the same process`)) + } + const promise = Promise.all([libs.resolveLibsDir(backend), libs.resolveAddonPath()]) + .then(([dir, addon]) => simplex.load(addon, libs.libPath(dir))) + loading = {backend, promise} + // a failed download or load can be retried + promise.catch(() => { loading = undefined }) + return promise +} + /** * Initialize chat controller * @param {number} [queueSize] - Size of internal queues, the core default is used when omitted. diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js deleted file mode 100644 index 4fe1bad959..0000000000 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ /dev/null @@ -1,266 +0,0 @@ -const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const extract = require('extract-zip'); - -const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.1.0-beta.5'; -const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); -// A locally built libsimplex, copied into libs/ rather than loaded in place: -// the addon's RUNPATH is $ORIGIN/../../libs. -const LIBS_DIR_OVERRIDE = process.env.SIMPLEX_LIBS_DIR || process.env.npm_config_simplex_libs_dir; -const LIB_NAMES = ['libsimplex.so', 'libsimplex.dylib', 'libsimplex.dll']; - -if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { - console.error(`✗ Invalid SIMPLEX_BACKEND: "${BACKEND}". Must be "sqlite" or "postgres".`); - process.exit(1); -} - -if (BACKEND === 'postgres' && (process.platform !== 'linux' || process.arch !== 'x64')) { - console.error(`✗ SIMPLEX_BACKEND=postgres is only supported on Linux x86_64.`); - process.exit(1); -} - -const ROOT_DIR = process.cwd(); // Root of the package being installed -const LIBS_DIR = path.join(ROOT_DIR, 'libs') -const INSTALLED_FILE = path.join(LIBS_DIR, 'installed.txt'); - -// Detect platform and architecture -function getPlatformInfo() { - const platform = process.platform; - const arch = process.arch; - - let platformName; - let archName; - - if (platform === 'linux') { - platformName = 'linux'; - } else if (platform === 'darwin') { - platformName = 'macos'; - } else if (platform === 'win32') { - platformName = 'windows'; - } else { - throw new Error(`Unsupported platform: ${platform}`); - } - - if (arch === 'x64') { - archName = 'x86_64'; - } else if (arch === 'arm64') { - archName = 'aarch64'; - } else { - throw new Error(`Unsupported architecture: ${arch}`); - } - - return { platformName, archName }; -} - -// Cleanup on libs version mismatch -function cleanLibsDirectory() { - if (fs.existsSync(LIBS_DIR)) { - console.log('Cleaning old libraries...'); - fs.rmSync(LIBS_DIR, { recursive: true, force: true }); - fs.mkdirSync(LIBS_DIR, { recursive: true }); - console.log('✓ Old libraries removed'); - } -} - -// Check if libraries are already installed with the correct version -function isAlreadyInstalled() { - if (!fs.existsSync(INSTALLED_FILE)) { - return false; - } - - try { - const installedVersion = fs.readFileSync(INSTALLED_FILE, 'utf-8').trim(); - const expectedVersion = `${RELEASE_TAG}:${BACKEND}`; - if (installedVersion === expectedVersion) { - console.log(`✓ Libraries version ${RELEASE_TAG}:${BACKEND} already installed`); - return true; - } else { - console.log(`Version mismatch: installed ${installedVersion}, need ${expectedVersion}`); - cleanLibsDirectory(); - return false; - } - } catch (err) { - console.warn(`Could not read installed.txt: ${err.message}`); - return false; - } -} - -// No version check: the files behind SIMPLEX_LIBS_DIR change on every rebuild. -function installFromOverride() { - if (!fs.existsSync(LIBS_DIR_OVERRIDE)) { - throw new Error(`SIMPLEX_LIBS_DIR does not exist: ${LIBS_DIR_OVERRIDE}`); - } - const lib = LIB_NAMES.find((name) => fs.existsSync(path.join(LIBS_DIR_OVERRIDE, name))); - if (!lib) { - throw new Error(`No ${LIB_NAMES.join(' / ')} in SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); - } - console.log(`Using libraries from SIMPLEX_LIBS_DIR: ${LIBS_DIR_OVERRIDE}`); - cleanLibsDirectory(); - copyDirSync(LIBS_DIR_OVERRIDE, LIBS_DIR); - // Not a release tag: a later install without the variable sees a mismatch - // and replaces these files with the released ones. - fs.writeFileSync(INSTALLED_FILE, `${LIBS_DIR_OVERRIDE}:${BACKEND}`, 'utf-8'); - console.log(`✓ Installed ${lib} and its runtime libraries from ${LIBS_DIR_OVERRIDE}`); -} - -async function install() { - try { - if (LIBS_DIR_OVERRIDE) { - installFromOverride(); - return; - } - - // Check if already installed - if (isAlreadyInstalled()) { - return; - } - - const { platformName, archName } = getPlatformInfo(); - const repoName = GITHUB_REPO.split('/')[1]; - const backendSuffix = BACKEND === 'postgres' ? '-postgres' : ''; - const zipFilename = `${repoName}-${platformName}-${archName}${backendSuffix}.zip`; - const ZIP_URL = `https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}/${zipFilename}`; - const ZIP_PATH = path.join(ROOT_DIR, zipFilename); - const TEMP_EXTRACT_DIR = path.join(ROOT_DIR, '.temp-extract'); - - console.log(`Detected: ${platformName} ${archName}`); - console.log(`Backend: ${BACKEND}`); - console.log(`Downloading: ${zipFilename}`); - - // Create libs directory - if (!fs.existsSync(LIBS_DIR)) { - fs.mkdirSync(LIBS_DIR, { recursive: true }); - } - - // Download zip with error handling - await downloadFile(ZIP_URL, ZIP_PATH); - - // Extract to temporary directory - console.log('Extracting to temporary directory...'); - if (!fs.existsSync(TEMP_EXTRACT_DIR)) { - fs.mkdirSync(TEMP_EXTRACT_DIR, { recursive: true }); - } - await extract(ZIP_PATH, { dir: TEMP_EXTRACT_DIR }); - - // Move libs folder contents to final location - console.log('Moving libraries to libs/...'); - const libsSourcePath = path.join(TEMP_EXTRACT_DIR, 'libs'); - - if (fs.existsSync(libsSourcePath)) { - // Copy all files from libs folder to LIBS_DIR - const files = fs.readdirSync(libsSourcePath); - files.forEach(file => { - const src = path.join(libsSourcePath, file); - const dest = path.join(LIBS_DIR, file); - - if (fs.statSync(src).isDirectory()) { - copyDirSync(src, dest); - } else { - fs.copyFileSync(src, dest); - } - }); - } else { - throw new Error('libs folder not found in zip archive'); - } - - // Write installed.txt with version - fs.writeFileSync(INSTALLED_FILE, `${RELEASE_TAG}:${BACKEND}`, 'utf-8'); - console.log(`✓ Wrote version ${RELEASE_TAG}:${BACKEND} to installed.txt`); - - // Cleanup - fs.rmSync(TEMP_EXTRACT_DIR, { recursive: true, force: true }); - fs.unlinkSync(ZIP_PATH); - console.log('✓ Installation complete'); - } catch (err) { - console.error('✗ Failed:', err.message); - process.exit(1); - } -} - -// Helper function to recursively copy directories -function copyDirSync(src, dest) { - if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true }); - } - const files = fs.readdirSync(src); - files.forEach(file => { - const srcFile = path.join(src, file); - const destFile = path.join(dest, file); - if (fs.statSync(srcFile).isDirectory()) { - copyDirSync(srcFile, destFile); - } else { - fs.copyFileSync(srcFile, destFile); - } - }); -} - -function downloadFile(url, dest) { - return new Promise((resolve, reject) => { - const file = fs.createWriteStream(dest); - - https.get(url, { headers: { 'User-Agent': 'Node.js' } }, (response) => { - // Handle redirects - if (response.statusCode === 302 || response.statusCode === 301) { - file.destroy(); - fs.unlink(dest, () => {}); - return downloadFile(response.headers.location, dest) - .then(resolve) - .catch(reject); - } - - // Handle 404 - if (response.statusCode === 404) { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error( - `Release artifact not found (404). Check:\n` + - ` - Repository exists: ${url.split('/releases')[0]}\n` + - ` - Release tag exists: ${RELEASE_TAG}\n` + - ` - Artifact filename is correct` - )); - return; - } - - // Handle 403 - if (response.statusCode === 403) { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error( - `Access denied (403). The repository may be private.\n` + - `Set GITHUB_TOKEN environment variable for private repos.` - )); - return; - } - - // Handle other HTTP errors - if (response.statusCode < 200 || response.statusCode >= 300) { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error( - `HTTP ${response.statusCode}: Failed to download from ${url}` - )); - return; - } - - response.pipe(file); - - file.on('finish', () => { - file.close(); - resolve(); - }); - - file.on('error', (err) => { - fs.unlink(dest, () => {}); - reject(new Error(`File write error: ${err.message}`)); - }); - }).on('error', (err) => { - file.destroy(); - fs.unlink(dest, () => {}); - reject(new Error(`Download error: ${err.message}`)); - }); - }); -} - -install(); diff --git a/packages/simplex-chat-nodejs/src/libs.ts b/packages/simplex-chat-nodejs/src/libs.ts new file mode 100644 index 0000000000..013e49f5d0 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/libs.ts @@ -0,0 +1,202 @@ +import * as crypto from "crypto" +import * as fs from "fs" +import * as http from "http" +import * as https from "https" +import * as os from "os" +import * as path from "path" +import {pipeline} from "stream/promises" +import extract = require("extract-zip") +import type {Backend} from "./core" +import {ADDON_RELEASE, LIBS_RELEASE, SHA256} from "./release" + +const GITHUB_REPO = "simplex-chat/simplex-chat-libs" +const REQUEST_TIMEOUT_MS = 60_000 +export const MAX_REDIRECTS = 5 +const PLATFORMS: {[platform: string]: {name: string, lib: string} | undefined} = { + linux: {name: "linux", lib: "libsimplex.so"}, + darwin: {name: "macos", lib: "libsimplex.dylib"}, + win32: {name: "windows", lib: "libsimplex.dll"}, +} +const ARCHS: {[arch: string]: string | undefined} = {x64: "x86_64", arm64: "aarch64"} +const SUPPORTED = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64"] + +function unsupported(platform: string, arch: string): Error { + return new Error(`Unsupported platform: ${platform}/${arch}; supported: ${SUPPORTED.join(", ")}`) +} + +export function platformTag(platform: string, arch: string): string { + const tag = `${PLATFORMS[platform]?.name}-${ARCHS[arch]}` + if (!SUPPORTED.includes(tag)) throw unsupported(platform, arch) + return tag +} + +function libName(platform: string = process.platform, arch: string = process.arch): string { + const lib = PLATFORMS[platform]?.lib + if (!lib) throw unsupported(platform, arch) + return lib +} + +export function cacheRoot(platform: string, env: NodeJS.ProcessEnv, home: string = os.homedir()): string { + if (platform === "darwin") return path.join(home, "Library", "Caches", "simplex-chat") + if (platform === "win32") { + if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is not set") + return path.join(env.LOCALAPPDATA, "simplex-chat") + } + return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "simplex-chat") +} + +export function libsAsset(backend: Backend, tag: string): string { + const suffix = backend === "postgres" ? "-postgres" : "" + return `simplex-chat-libs-${tag}${suffix}.zip` +} + +export function addonAsset(tag: string): string { + return `simplex-chat-nodejs-${tag}.node` +} + +export function releaseUrl(release: string, asset: string): string { + return `https://github.com/${GITHUB_REPO}/releases/download/${release}/${asset}` +} + +function pinnedSha256(asset: string): string { + const sha256 = SHA256[asset] + if (!sha256) throw new Error(`no SHA-256 is pinned for ${asset}`) + return sha256 +} + +export function libPath(dir: string): string { + return path.join(dir, libName()) +} + +export async function resolveLibsDir( + backend: Backend, + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform, + arch: string = process.arch +): Promise { + const lib = libName(platform, arch) + if (env.SIMPLEX_LIBS_DIR) { + const dir = path.resolve(env.SIMPLEX_LIBS_DIR) + if (!fs.existsSync(path.join(dir, lib))) throw new Error(`SIMPLEX_LIBS_DIR has no ${lib}: ${dir}`) + return dir + } + const tag = platformTag(platform, arch) + if (backend === "postgres" && tag !== "linux-x86_64") { + throw new Error(`postgres backend is only supported on linux-x86_64; current platform is ${tag}`) + } + const target = path.resolve(cacheRoot(platform, env), LIBS_RELEASE, backend) + if (!fs.existsSync(path.join(target, lib))) { + const asset = libsAsset(backend, tag) + await installLibs(releaseUrl(LIBS_RELEASE, asset), pinnedSha256(asset), target, lib) + } + return target +} + +export async function resolveAddonPath( + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform, + arch: string = process.arch +): Promise { + if (env.SIMPLEX_ADDON_PATH) { + const file = path.resolve(env.SIMPLEX_ADDON_PATH) + if (!fs.existsSync(file)) throw new Error(`SIMPLEX_ADDON_PATH does not exist: ${file}`) + return file + } + if (!ADDON_RELEASE) throw new Error("no prebuilt Node.js add-on is pinned in this version; set SIMPLEX_ADDON_PATH") + const asset = addonAsset(platformTag(platform, arch)) + const target = path.resolve(cacheRoot(platform, env), ADDON_RELEASE, asset) + if (!fs.existsSync(target)) await installAddon(releaseUrl(ADDON_RELEASE, asset), pinnedSha256(asset), target) + return target +} + +export function installLibs(url: string, sha256: string, target: string, lib: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise { + return inTempDir(target, async tmp => { + process.stderr.write(`Downloading libsimplex from ${url} ...\n`) + const zipPath = path.join(tmp, "libs.zip") + await download(url, zipPath, timeoutMs) + await verifySha256(zipPath, sha256, url) + await extract(zipPath, {dir: tmp}) + const extracted = path.join(tmp, "libs") + if (!fs.existsSync(path.join(extracted, lib))) throw new Error(`libs/${lib} missing from ${url}`) + await renameInto(extracted, target, path.join(target, lib)) + }) +} + +export function installAddon(url: string, sha256: string, target: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise { + return inTempDir(target, async tmp => { + process.stderr.write(`Downloading the Node.js add-on from ${url} ...\n`) + const file = path.join(tmp, path.basename(target)) + await download(url, file, timeoutMs) + await verifySha256(file, sha256, url) + await renameInto(file, target, target) + }) +} + +async function verifySha256(file: string, expected: string, url: string): Promise { + const hash = crypto.createHash("sha256") + for await (const chunk of fs.createReadStream(file)) hash.update(chunk) + const actual = hash.digest("hex") + if (actual !== expected) throw new Error(`SHA-256 of ${url} is ${actual}, expected ${expected}`) +} + +// The temp dir is next to the target, so the final rename stays on one filesystem. +async function inTempDir(target: string, install: (tmp: string) => Promise): Promise { + const parent = path.dirname(target) + await fs.promises.mkdir(parent, {recursive: true}) + const tmp = await fs.promises.mkdtemp(path.join(parent, ".download-")) + try { + await install(tmp) + } finally { + await fs.promises.rm(tmp, {recursive: true, force: true}) + } +} + +async function renameInto(from: string, target: string, installed: string): Promise { + try { + await fs.promises.rename(from, target) + } catch (e) { + // Another process installed the same version first; its files are identical. + // Windows reports renaming onto an existing directory or a loaded file as EPERM. + const code = (e as NodeJS.ErrnoException).code + const lost = (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") && fs.existsSync(target) + if (!lost) throw e + if (!fs.existsSync(installed)) { + throw new Error(`another process partially populated ${target} but ${path.basename(installed)} is missing; remove it manually and retry`) + } + } +} + +function download(url: string, dest: string, timeoutMs: number, redirects = 0): Promise { + return new Promise((resolve, reject) => { + const get = url.startsWith("https:") ? https.get : http.get + // Once a response arrives, request errors are ignored: a download settles via pipeline, which + // waits for the file to close, so tmp cleanup cannot fail with EBUSY on Windows. + let res: http.IncomingMessage | undefined + const req = get(url, {headers: {"User-Agent": "simplex-chat-nodejs"}, timeout: timeoutMs}, response => { + res = response + const status = response.statusCode ?? 0 + const location = response.headers.location + if (status >= 300 && status < 400 && location) { + response.resume() + if (redirects >= MAX_REDIRECTS) return reject(new Error(`too many redirects downloading ${url}`)) + let next: URL + try { + next = new URL(location, url) + } catch (e) { + return reject(e) + } + if (next.protocol !== new URL(url).protocol) return reject(new Error(`redirect from ${url} to ${next} changes protocol`)) + download(next.toString(), dest, timeoutMs, redirects + 1).then(resolve, reject) + return + } + if (status !== 200) { + response.resume() + return reject(new Error(`HTTP ${status} downloading ${url}`)) + } + pipeline(response, fs.createWriteStream(dest)).then(resolve, reject) + }) + const abort = (err: Error) => (res ? res.destroy(err) : req.destroy(err)) + req.on("timeout", () => abort(new Error(`timeout downloading ${url}`))) + req.on("error", e => { if (!res) reject(e) }) + }) +} diff --git a/packages/simplex-chat-nodejs/src/release.ts b/packages/simplex-chat-nodejs/src/release.ts new file mode 100644 index 0000000000..ad9dfa4c78 --- /dev/null +++ b/packages/simplex-chat-nodejs/src/release.ts @@ -0,0 +1,19 @@ +// Written by scripts/ci/packages_release.py from the simplex-chat-libs releases. + +export const LIBS_RELEASE = "core-7.1.0.12" + +export const ADDON_RELEASE: string | undefined = "nodejs-addon-308c0960c707" + +export const SHA256: {readonly [asset: string]: string} = { + "simplex-chat-libs-linux-aarch64.zip": "924b8e14eaba5e309fa584c4bfe598421cf64983d63790c1c45247f592f60055", + "simplex-chat-libs-linux-x86_64-postgres.zip": "323f908b45dde751d6082f8885d07d81fba8a5ab8b2df7d3b8ab3a87e56299e5", + "simplex-chat-libs-linux-x86_64.zip": "995409e963db408bcf896361b669944484f845b6744fefe479aebb0ca2e7c5cd", + "simplex-chat-libs-macos-aarch64.zip": "64464aa48b996e5a96b4aa651bc57bc69e412d3c3804b589c67bd9dc52faddbf", + "simplex-chat-libs-macos-x86_64.zip": "94664f96ff7a2cd5a419096e7e843928faa31cb3addb8869fa1e4b5a0d0b3629", + "simplex-chat-libs-windows-x86_64.zip": "2e10844e5c119921139c373ee2971341aceef7dc4198429d3be6b1570a009d15", + "simplex-chat-nodejs-linux-aarch64.node": "3636a82c56c48229e0326fa03d6ceb5c16dd3afbcc1e86337783dc53a7328d67", + "simplex-chat-nodejs-linux-x86_64.node": "55f3479b03fdb57591179ed730da744f1aa362e7529df55221b60028415746e9", + "simplex-chat-nodejs-macos-aarch64.node": "a3472872fb0816f435f52ee5471088e6545ebffb95e89fbf8e1ab82275f2d56e", + "simplex-chat-nodejs-macos-x86_64.node": "290901f7f227019a8cee3acc832541c930c4926fc08110df25169810a9002f5a", + "simplex-chat-nodejs-windows-x86_64.node": "ec689373b4f0fef73253a8328f60b8e902a45ea67e756d42a1f759b44dfd7339", +} diff --git a/packages/simplex-chat-nodejs/src/simplex.d.ts b/packages/simplex-chat-nodejs/src/simplex.d.ts index 1e0ca825a6..0187556afd 100644 --- a/packages/simplex-chat-nodejs/src/simplex.d.ts +++ b/packages/simplex-chat-nodejs/src/simplex.d.ts @@ -1,5 +1,6 @@ // These functions are defined in CPP add-on ../cpp/simplex.cc +export function load(addonPath: string, libPath: string): void export function chat_migrate_init(dbPath: string, dbKey: string, confirm: string): Promise<[bigint, string]> export function chat_migrate_init_queue(dbPath: string, dbKey: string, confirm: string, queueSize: number): Promise<[bigint, string]> export function chat_close_store(ctrl: bigint): Promise diff --git a/packages/simplex-chat-nodejs/src/simplex.js b/packages/simplex-chat-nodejs/src/simplex.js index fd26c67438..3bdfb528df 100644 --- a/packages/simplex-chat-nodejs/src/simplex.js +++ b/packages/simplex-chat-nodejs/src/simplex.js @@ -1 +1,22 @@ -module.exports = require("../build/Release/simplex") +// The add-on is downloaded from the libsimplex release, so it is loaded at runtime by core.loadLibrary. +const NOT_LOADED = "libsimplex is not loaded, call core.loadLibrary(backend) first" +const BINDINGS = ["chat_migrate_init", "chat_migrate_init_queue", "chat_close_store", "chat_send_cmd", "chat_recv_msg_wait", + "chat_write_file", "chat_read_file", "chat_encrypt_file", "chat_decrypt_file"] + +let addon + +exports.load = (addonPath, libPath) => { + if (!addon) { + const addonModule = {exports: {}} + process.dlopen(addonModule, addonPath) + addon = addonModule.exports + } + addon.load(libPath) +} + +for (const name of BINDINGS) { + exports[name] = (...args) => { + if (!addon) throw new Error(NOT_LOADED) + return addon[name](...args) + } +} diff --git a/packages/simplex-chat-nodejs/tests/api.unit.test.ts b/packages/simplex-chat-nodejs/tests/api.unit.test.ts index b6b92bf93b..b4ae456af9 100644 --- a/packages/simplex-chat-nodejs/tests/api.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.unit.test.ts @@ -5,6 +5,7 @@ import * as core from "../src/core" const user = {userId: 1} as T.User async function chatWithResponse(response: object): Promise { + jest.spyOn(core, "loadLibrary").mockResolvedValue() jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) jest.spyOn(core, "chatSendCmd").mockResolvedValue(response as ChatResponse) return api.ChatApi.init({type: "sqlite", filePrefix: "unused"}) @@ -45,6 +46,7 @@ describe("documented success responses", () => { describe("startChat lifecycle", () => { function chatWithResponses(...responses: object[]): Promise { + jest.spyOn(core, "loadLibrary").mockResolvedValue() jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) jest.spyOn(core, "chatRecvMsgWait").mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(undefined), 10))) const send = jest.spyOn(core, "chatSendCmd") @@ -110,9 +112,40 @@ describe("startChat lifecycle", () => { await chat.stopChat() }) + it("passes event errors to chatError subscribers", async () => { + const chat = await chatWithResponses({type: "chatStarted"}, {type: "chatStopped"}) + const chatError = {type: "errorStore", storeError: {type: "connectionNotFound", agentConnId: "Y29ubg=="}} as T.ChatError + jest.mocked(core.chatRecvMsgWait).mockRejectedValueOnce(new core.ChatAPIError("Chat event error (see chatError property)", chatError)) + const received = new Promise(resolve => chat.on("chatError", event => resolve(event))) + await chat.startChat() + await expect(received).resolves.toEqual({type: "chatError", chatError}) + await chat.stopChat() + }) + it("receives with a 500 ms wait", async () => { const chat = await chatWithResponses() await chat.recvChatEvent() expect(core.chatRecvMsgWait).toHaveBeenCalledWith(BigInt(1), 500_000) }) }) + +describe("ChatApi.init", () => { + it("loads the library for the configured backend before opening the database", async () => { + let loaded!: () => void + const load = jest.spyOn(core, "loadLibrary").mockReturnValue(new Promise(resolve => { loaded = resolve })) + const migrate = jest.spyOn(core, "chatMigrateInit").mockResolvedValue(BigInt(1)) + const init = api.ChatApi.init({type: "postgres", connectionString: "postgres://unused"}) + await new Promise(setImmediate) + expect(load).toHaveBeenCalledWith("postgres") + expect(migrate).not.toHaveBeenCalled() + loaded() + await init + expect(migrate).toHaveBeenCalled() + }) + + it("rejects an invalid config before loading the library", async () => { + const load = jest.spyOn(core, "loadLibrary").mockResolvedValue() + await expect(api.ChatApi.init({type: "mysql"} as unknown as api.DbConfig)).rejects.toThrow('Invalid DbConfig: {"type":"mysql"}') + expect(load).not.toHaveBeenCalled() + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/core.test.ts b/packages/simplex-chat-nodejs/tests/core.test.ts index 8eb106a5b5..608c3939cd 100644 --- a/packages/simplex-chat-nodejs/tests/core.test.ts +++ b/packages/simplex-chat-nodejs/tests/core.test.ts @@ -2,8 +2,19 @@ import {execFile, spawnSync} from "child_process"; import * as fs from "fs"; import * as path from "path"; import {core} from "../src/index"; +import * as libs from "../src/libs"; +import * as simplex from "../src/simplex"; describe("Core tests", () => { + let libPath: string; + let addonPath: string; + // the first run downloads libsimplex + beforeAll(async () => { + const libsDir = await libs.resolveLibsDir("sqlite"); + libPath = libs.libPath(libsDir); + addonPath = await libs.resolveAddonPath(); + await core.loadLibrary("sqlite"); + }, 300000); const tmpDir = "./tests/tmp"; const dbPath = path.join(tmpDir, "simplex_v1"); @@ -125,9 +136,10 @@ describe("Core tests", () => { const start = Date.now(); await fs.promises.stat(tmpDir); expect(Date.now() - start).toBeLessThan(200); + // one receiver thread per controller runs the 4 receives in turn await Promise.all(receives); await stopAndClose(ctrl); - }, 10000); + }, 30000); const itOnLinux = process.platform === "linux" ? it : it.skip; @@ -157,7 +169,7 @@ describe("Core tests", () => { expect(elapsed).toBeLessThan(1800); await stopAndClose(ctrlA); await stopAndClose(ctrlB); - }, 10000); + }); it("should receive events of one controller in order", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); @@ -171,7 +183,7 @@ describe("Core tests", () => { {type: "timedAction", action: "second"} ]); await stopAndClose(ctrl); - }, 10000); + }); it("should close the store while a receive is in flight", async () => { const ctrl = await core.chatMigrateInit(dbPath, "key", core.MigrationConfirmation.YesUp); @@ -189,36 +201,70 @@ describe("Core tests", () => { expect({closed, timerDelayBelow100ms: timerDelay < 100}).toEqual({closed: false, timerDelayBelow100ms: true}); await close; expect(await receives).toEqual([{event: undefined}, {error: "chat receiver stopped"}]); - }, 10000); + }); + + it("should accept loading libsimplex again from the same path", () => { + expect(() => simplex.load(addonPath, libPath)).not.toThrow(); + }); + + it("should refuse to load libsimplex from another path", () => { + expect(() => simplex.load(addonPath, path.resolve("other", "libsimplex.so"))).toThrow(`libsimplex already loaded from ${libPath}`); + }); + + it("should load libsimplex after a failed load and in several workers", () => { + const addon = path.resolve(__dirname, "..", "build", "Release", "simplex.node"); + const childDbPath = path.resolve(tmpDir, "simplex_workers"); + const script = ` + const {Worker} = require("worker_threads"); + const simplex = require(${JSON.stringify(addon)}); + try { simplex.load(${JSON.stringify(addon)}) } catch (e) { console.log(JSON.stringify(e.message.endsWith("does not export hs_init_with_rtsopts"))) } + const load = ${JSON.stringify(`require(${JSON.stringify(addon)}).load(${JSON.stringify(libPath)})`)}; + const workers = Array.from({length: 4}, () => new Worker(load, {eval: true})); + Promise.all(workers.map((w) => new Promise((resolve, reject) => w.on("exit", resolve).on("error", reject)))) + .then(async (codes) => { + simplex.load(${JSON.stringify(libPath)}); + const [ctrl] = await simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp"); + await simplex.chat_send_cmd(ctrl, "/_stop"); + console.log(JSON.stringify({codes, closed: await simplex.chat_close_store(ctrl) === ""})); + }); + `; + const child = spawnSync(process.execPath, ["-e", script], {timeout: 30000, encoding: "utf8"}); + if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr); + expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()}) + .toEqual({status: 0, signal: null, stdout: 'true\n{"codes":[0,0,0,0],"closed":true}'}); + }, 35000); it("should let the process exit while a receiver is idle", () => { const childDbPath = path.resolve(tmpDir, "simplex_child"); const script = ` const simplex = require("./build/Release/simplex.node"); + simplex.load(${JSON.stringify(libPath)}); simplex.chat_migrate_init(${JSON.stringify(childDbPath)}, "key", "yesUp") .then(([ctrl]) => simplex.chat_recv_msg_wait(ctrl, 1)) .then((res) => console.log("received " + JSON.stringify(res))); `; - const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 10000, encoding: "utf8"}); + const child = spawnSync(process.execPath, ["-e", script], {cwd: path.join(__dirname, ".."), timeout: 25000, encoding: "utf8"}); if (child.status !== 0 || child.signal !== null) console.log("child stderr:", child.stderr); expect({status: child.status, signal: child.signal, stdout: child.stdout.trim()}) .toEqual({status: 0, signal: null, stdout: 'received ""'}); - }, 15000); + }); it("should not crash when closing stopped controllers repeatedly", async () => { const script = ` const fs = require("fs"), path = require("path"); const simplex = require("./build/Release/simplex.node"); + simplex.load(${JSON.stringify(libPath)}); (async () => { + // a new database runs every migration, each committed with a disk flush that takes up to 30 ms on CI runners + const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-")); for (let i = 0; i < 40; i++) { - const dir = fs.mkdtempSync(path.join(${JSON.stringify(path.resolve(tmpDir))}, "close-")); const [ctrl] = await simplex.chat_migrate_init(path.join(dir, "simplex"), "key", "yesUp"); await simplex.chat_send_cmd(ctrl, "/v"); await simplex.chat_send_cmd(ctrl, "/_stop"); const res = await simplex.chat_close_store(ctrl); - fs.rmSync(dir, {recursive: true, force: true}); if (res !== "") throw new Error("close failed: " + res); } + fs.rmSync(dir, {recursive: true, force: true}); })(); `; const runChild = () => new Promise<{code: number | null, signal: NodeJS.Signals | null, stderr: string}>((resolve) => { diff --git a/packages/simplex-chat-nodejs/tests/libs.test.ts b/packages/simplex-chat-nodejs/tests/libs.test.ts new file mode 100644 index 0000000000..c8a65f6bdd --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/libs.test.ts @@ -0,0 +1,466 @@ +import * as fs from "fs" +import * as http from "http" +import * as https from "https" +import * as os from "os" +import * as path from "path" +import {AddressInfo} from "net" +import * as crypto from "crypto" +import {addonAsset, cacheRoot, installAddon, installLibs, libPath, libsAsset, MAX_REDIRECTS, platformTag, releaseUrl, resolveAddonPath, resolveLibsDir} from "../src/libs" +import * as release from "../src/release" +import {main, parseInstallArgs} from "../src/cli" +import * as libs from "../src/libs" +import {storedZip} from "./zip" + +jest.mock("../src/release", () => ({LIBS_RELEASE: "core-test", ADDON_RELEASE: "nodejs-addon-test", SHA256: {}})) + +const LIB = "libtest.so" +const ADDON = Buffer.from("addon") +const PINNED = release.SHA256 as {[asset: string]: string} + +function sha256(data: Buffer): string { + return crypto.createHash("sha256").update(data).digest("hex") +} +const LINUX_LIB = "libsimplex.so" +const {XDG_CACHE_HOME, LOCALAPPDATA, SIMPLEX_LIBS_DIR, SIMPLEX_ADDON_PATH} = process.env + +function setEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name] + else process.env[name] = value +} + +// A path resolution regression must neither download nor write into the real user cache. +let home: string +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "libs-home-")) + jest.spyOn(os, "homedir").mockReturnValue(home) + jest.spyOn(https, "get").mockImplementation(() => { throw new Error("unexpected download") }) + setEnv("XDG_CACHE_HOME", home) + setEnv("LOCALAPPDATA", home) + setEnv("SIMPLEX_LIBS_DIR", undefined) + setEnv("SIMPLEX_ADDON_PATH", undefined) +}) +afterEach(() => { + jest.restoreAllMocks() + setEnv("XDG_CACHE_HOME", XDG_CACHE_HOME) + setEnv("LOCALAPPDATA", LOCALAPPDATA) + setEnv("SIMPLEX_LIBS_DIR", SIMPLEX_LIBS_DIR) + setEnv("SIMPLEX_ADDON_PATH", SIMPLEX_ADDON_PATH) + fs.rmSync(home, {recursive: true, force: true}) +}) + +describe("paths", () => { + let libsDir: string + + beforeEach(() => { + libsDir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-dir-")) + fs.writeFileSync(libPath(libsDir), "lib") + }) + afterEach(() => fs.rmSync(libsDir, {recursive: true, force: true})) + + it("uses the Python cache layout", () => { + expect(cacheRoot("linux", {XDG_CACHE_HOME: "/x"}, "/h")).toBe(path.join("/x", "simplex-chat")) + expect(cacheRoot("linux", {}, "/h")).toBe(path.join("/h", ".cache", "simplex-chat")) + expect(cacheRoot("darwin", {}, "/h")).toBe(path.join("/h", "Library", "Caches", "simplex-chat")) + expect(cacheRoot("win32", {LOCALAPPDATA: "C:\\L"}, "/h")).toBe(path.join("C:\\L", "simplex-chat")) + expect(() => cacheRoot("win32", {}, "/h")).toThrow("LOCALAPPDATA is not set") + }) + + it.each([ + ["linux", "x64", "linux-x86_64"], + ["linux", "arm64", "linux-aarch64"], + ["darwin", "x64", "macos-x86_64"], + ["darwin", "arm64", "macos-aarch64"], + ["win32", "x64", "windows-x86_64"], + ])("tags %s/%s as %s", (platform, arch, tag) => { + expect(platformTag(platform, arch)).toBe(tag) + }) + + it("rejects unsupported platforms", () => { + expect(() => platformTag("win32", "arm64")).toThrow("Unsupported platform") + }) + + it("rejects an unsupported OS with its arch", async () => { + await expect(resolveLibsDir("sqlite", {}, "freebsd", "riscv64")).rejects.toThrow("Unsupported platform: freebsd/riscv64; supported: ") + }) + + it("uses the release URLs", () => { + const releases = "https://github.com/simplex-chat/simplex-chat-libs/releases/download" + expect(releaseUrl("core-7.1.0.11", libsAsset("sqlite", "macos-aarch64"))).toBe(`${releases}/core-7.1.0.11/simplex-chat-libs-macos-aarch64.zip`) + expect(releaseUrl("core-7.1.0.11", libsAsset("postgres", "linux-x86_64"))).toBe(`${releases}/core-7.1.0.11/simplex-chat-libs-linux-x86_64-postgres.zip`) + expect(releaseUrl("nodejs-addon-1", addonAsset("windows-x86_64"))).toBe(`${releases}/nodejs-addon-1/simplex-chat-nodejs-windows-x86_64.node`) + }) + + it("resolves an absolute SIMPLEX_LIBS_DIR", async () => { + await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir})).resolves.toBe(libsDir) + }) + + it("uses SIMPLEX_LIBS_DIR on an unsupported CPU", async () => { + fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib") + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm")).resolves.toBe(libsDir) + }) + + it("uses SIMPLEX_LIBS_DIR for postgres outside linux-x86_64", async () => { + fs.writeFileSync(path.join(libsDir, LINUX_LIB), "lib") + await expect(resolveLibsDir("postgres", {SIMPLEX_LIBS_DIR: libsDir}, "linux", "arm64")).resolves.toBe(libsDir) + }) + + it("returns a relative SIMPLEX_LIBS_DIR as absolute", async () => { + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: path.relative(process.cwd(), libsDir)})).resolves.toBe(libsDir) + }) + + it.each([ + ["linux", "x64", "libsimplex.so"], + ["darwin", "arm64", "libsimplex.dylib"], + ["win32", "x64", "libsimplex.dll"], + ])("looks for the %s library name", async (platform, arch, lib) => { + fs.rmSync(libPath(libsDir)) + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir}, platform, arch)).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${lib}: `) + }) + + it("returns a relative SIMPLEX_ADDON_PATH as absolute", async () => { + const addon = path.join(libsDir, "simplex.node") + fs.writeFileSync(addon, "addon") + await expect(resolveAddonPath({SIMPLEX_ADDON_PATH: path.relative(process.cwd(), addon)})).resolves.toBe(addon) + }) + + it("rejects a missing SIMPLEX_ADDON_PATH", async () => { + const addon = path.join(libsDir, "simplex.node") + await expect(resolveAddonPath({SIMPLEX_ADDON_PATH: addon})).rejects.toThrow(`SIMPLEX_ADDON_PATH does not exist: ${addon}`) + }) + + it("rejects a SIMPLEX_LIBS_DIR without libsimplex", async () => { + fs.rmSync(libPath(libsDir)) + await expect(resolveLibsDir("sqlite", {SIMPLEX_LIBS_DIR: libsDir})).rejects.toThrow(`SIMPLEX_LIBS_DIR has no ${path.basename(libPath(libsDir))}: ${libsDir}`) + }) +}) + +describe("installLibs", () => { + let server: http.Server + let base: string + let tmp: string + const routes: {[p: string]: (res: http.ServerResponse) => void} = {} + + beforeAll(async () => { + server = http.createServer((req, res) => { + const route = routes[req.url ?? ""] + if (route) route(res) + else res.writeHead(404).end() + }) + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + }) + + afterAll(() => new Promise(resolve => server.close(() => resolve()))) + + beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), "libs-test-")) }) + afterEach(() => fs.rmSync(tmp, {recursive: true, force: true})) + + const good = storedZip({[`libs/${LIB}`]: "lib", "libs/libHSdep.so": "dep"}) + const GOOD_SHA = sha256(good) + routes["/good.zip"] = res => res.writeHead(200).end(good) + routes["/redirect.zip"] = res => res.writeHead(302, {location: "/good.zip"}).end() + const nolib = storedZip({"other/file": "x"}) + routes["/nolib.zip"] = res => res.writeHead(200).end(nolib) + routes["/stall.zip"] = res => { res.writeHead(200); res.write(good.subarray(0, 10)) } + let loopRequests = 0 + routes["/loop.zip"] = res => { loopRequests++; res.writeHead(302, {location: "/loop.zip"}).end() } + routes["/to-http.zip"] = res => res.writeHead(302, {location: `${base}/good.zip`}).end() + routes["/bad-location.zip"] = res => res.writeHead(302, {location: "http://[bad"}).end() + routes["/no-location.zip"] = res => res.writeHead(302).end() + routes["/hang.zip"] = () => {} + routes["/cross-host.zip"] = res => res.writeHead(302, {location: "https://objects.libs.test/good.zip"}).end() + let downloadDirs: string[] = [] + routes["/observed.zip"] = res => { downloadDirs = leftovers(); res.writeHead(200).end(good) } + + function leftovers(): string[] { + return fs.readdirSync(tmp).filter(f => f.startsWith(".download-")) + } + + // Serves https requests from a local http route (the requested path by default), recording the requested URLs. + function mockHttps(route?: string): string[] { + const requested: string[] = [] + jest.spyOn(https, "get").mockImplementation(((url: string, options: http.RequestOptions, cb: (res: http.IncomingMessage) => void) => { + requested.push(url) + return http.get(`${base}${route ?? new URL(url).pathname}`, options, cb) + }) as unknown as typeof https.get) + return requested + } + + function renameFails(code: string): void { + jest.spyOn(fs.promises, "rename").mockRejectedValue(Object.assign(new Error(code), {code})) + } + + beforeEach(() => { jest.spyOn(console, "error").mockImplementation(() => {}) }) + + it("follows a redirect and installs libs/", async () => { + const target = path.join(tmp, "v1", "sqlite") + await installLibs(`${base}/redirect.zip`, GOOD_SHA, target, LIB) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + expect(fs.existsSync(path.join(target, "libHSdep.so"))).toBe(true) + }) + + it("downloads into a temp dir next to the target", async () => { + await installLibs(`${base}/observed.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB) + expect(downloadDirs).toHaveLength(1) + expect(leftovers()).toEqual([]) + }) + + it("stops following a redirect loop", async () => { + loopRequests = 0 + await expect(installLibs(`${base}/loop.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("too many redirects") + expect(loopRequests).toBe(MAX_REDIRECTS + 1) + expect(leftovers()).toEqual([]) + }) + + it("follows an https redirect to another host", async () => { + const requested = mockHttps() + const target = path.join(tmp, "sqlite") + await installLibs("https://libs.test/cross-host.zip", GOOD_SHA, target, LIB) + expect(requested).toEqual(["https://libs.test/cross-host.zip", "https://objects.libs.test/good.zip"]) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + }) + + it("rejects a redirect from https to http", async () => { + mockHttps("/to-http.zip") + await expect(installLibs("https://libs.test/libs.zip", GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("changes protocol") + expect(leftovers()).toEqual([]) + }) + + it("rejects an invalid redirect location", async () => { + await expect(installLibs(`${base}/bad-location.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("Invalid URL") + expect(leftovers()).toEqual([]) + }) + + it("rejects a redirect without a location", async () => { + await expect(installLibs(`${base}/no-location.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("HTTP 302") + expect(leftovers()).toEqual([]) + }) + + it("rejects 404 and cleans up", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/missing.zip`, GOOD_SHA, target, LIB)).rejects.toThrow("HTTP 404") + expect(leftovers()).toEqual([]) + expect(fs.existsSync(target)).toBe(false) + }) + + it("rejects a zip without the lib", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/nolib.zip`, sha256(nolib), target, LIB)).rejects.toThrow(`libs/${LIB} missing`) + expect(leftovers()).toEqual([]) + }) + + it("times out a request without a response", async () => { + await expect(installLibs(`${base}/hang.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB, 200)).rejects.toThrow("timeout") + expect(leftovers()).toEqual([]) + }) + + it("times out a stalled download", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/stall.zip`, GOOD_SHA, target, LIB, 200)).rejects.toThrow("timeout") + expect(leftovers()).toEqual([]) + }) + + it.each(["sqlite", "postgres"] as const)("installs %s into a relative XDG_CACHE_HOME and returns an absolute path", async backend => { + const zip = storedZip({[`libs/${LINUX_LIB}`]: "lib"}) + const asset = libsAsset(backend, "linux-x86_64") + PINNED[asset] = sha256(zip) + routes["/libsimplex.zip"] = res => res.writeHead(200).end(zip) + const requested = mockHttps("/libsimplex.zip") + const cache = path.relative(process.cwd(), path.join(tmp, "cache")) + const dir = await resolveLibsDir(backend, {XDG_CACHE_HOME: cache}, "linux", "x64") + expect(requested).toEqual([releaseUrl("core-test", asset)]) + expect(path.isAbsolute(dir)).toBe(true) + expect(dir).toBe(path.join(tmp, "cache", "simplex-chat", "core-test", backend)) + expect(fs.readFileSync(path.join(dir, LINUX_LIB), "utf8")).toBe("lib") + }) + + it("downloads the add-on into the cache", async () => { + routes["/addon.node"] = res => res.writeHead(200).end(ADDON) + PINNED[addonAsset("linux-x86_64")] = sha256(ADDON) + const requested = mockHttps("/addon.node") + const addon = await resolveAddonPath({XDG_CACHE_HOME: tmp}, "linux", "x64") + const version = path.join(tmp, "simplex-chat", "nodejs-addon-test") + expect(requested).toEqual([releaseUrl("nodejs-addon-test", addonAsset("linux-x86_64"))]) + expect(addon).toBe(path.join(version, "simplex-chat-nodejs-linux-x86_64.node")) + expect(fs.readFileSync(addon, "utf8")).toBe("addon") + expect(fs.readdirSync(version)).toEqual(["simplex-chat-nodejs-linux-x86_64.node"]) + }) + + it("does not download a cached add-on", async () => { + const requested = mockHttps("/addon.node") + const cached = path.join(cacheRoot("darwin", {}), "nodejs-addon-test", "simplex-chat-nodejs-macos-aarch64.node") + fs.mkdirSync(path.dirname(cached), {recursive: true}) + fs.writeFileSync(cached, "addon") + await expect(resolveAddonPath({}, "darwin", "arm64")).resolves.toBe(cached) + expect(requested).toEqual([]) + }) + + it("rejects a download with another SHA-256 and cleans up", async () => { + const target = path.join(tmp, "sqlite") + await expect(installLibs(`${base}/good.zip`, sha256(ADDON), target, LIB)).rejects.toThrow(`SHA-256 of ${base}/good.zip is ${GOOD_SHA}, expected ${sha256(ADDON)}`) + expect(fs.readdirSync(tmp)).toEqual([]) + }) + + it("rejects an add-on with another SHA-256", async () => { + routes["/addon.node"] = res => res.writeHead(200).end(ADDON) + const target = path.join(tmp, "simplex.node") + await expect(installAddon(`${base}/addon.node`, GOOD_SHA, target)).rejects.toThrow("SHA-256 of") + expect(fs.readdirSync(tmp)).toEqual([]) + }) + + it("rejects an asset without a pinned SHA-256", async () => { + mockHttps("/good.zip") + await expect(resolveLibsDir("sqlite", {XDG_CACHE_HOME: tmp}, "darwin", "x64")) + .rejects.toThrow("no SHA-256 is pinned for simplex-chat-libs-macos-x86_64.zip") + }) + + it("requires SIMPLEX_ADDON_PATH without a pinned add-on release", async () => { + jest.replaceProperty(release, "ADDON_RELEASE", undefined) + await expect(resolveAddonPath({XDG_CACHE_HOME: tmp}, "linux", "x64")) + .rejects.toThrow("no prebuilt Node.js add-on is pinned in this version; set SIMPLEX_ADDON_PATH") + }) + + it("rejects a missing add-on and cleans up", async () => { + const target = path.join(tmp, "simplex.node") + await expect(installAddon(`${base}/missing.node`, sha256(ADDON), target)).rejects.toThrow("HTTP 404") + expect(fs.readdirSync(tmp)).toEqual([]) + }) + + it("accepts EPERM from rename onto an installed add-on", async () => { + routes["/addon.node"] = res => res.writeHead(200).end(ADDON) + const target = path.join(tmp, "simplex.node") + fs.writeFileSync(target, "addon") + renameFails("EPERM") + await expect(installAddon(`${base}/addon.node`, sha256(ADDON), target)).resolves.toBeUndefined() + expect(leftovers()).toEqual([]) + }) + + it("does not download a cached lib", async () => { + const requested = mockHttps("/good.zip") + const cached = path.join(tmp, "simplex-chat", "core-test", "postgres") + fs.mkdirSync(cached, {recursive: true}) + fs.writeFileSync(path.join(cached, LINUX_LIB), "lib") + await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, "linux", "x64")).resolves.toBe(cached) + expect(requested).toEqual([]) + }) + + it("rejects a target populated without the lib", async () => { + const target = path.join(tmp, "sqlite") + fs.mkdirSync(target) + fs.writeFileSync(path.join(target, "other"), "x") + await expect(installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB)).rejects.toThrow(`another process partially populated ${target}`) + expect(leftovers()).toEqual([]) + }) + + it.each([["linux", "arm64"], ["darwin", "arm64"]])("rejects postgres on %s/%s without downloading", async (platform, arch) => { + const requested = mockHttps("/good.zip") + await expect(resolveLibsDir("postgres", {XDG_CACHE_HOME: tmp}, platform, arch)).rejects.toThrow("postgres backend is only supported on linux-x86_64") + expect(requested).toEqual([]) + }) + + it.each(["EPERM", "EEXIST"])("accepts %s from rename onto an installed target", async code => { + const target = path.join(tmp, "sqlite") + fs.mkdirSync(target) + fs.writeFileSync(path.join(target, LIB), "lib") + renameFails(code) + await expect(installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB)).resolves.toBeUndefined() + expect(leftovers()).toEqual([]) + }) + + it("rethrows EPERM from rename when the target does not exist", async () => { + renameFails("EPERM") + await expect(installLibs(`${base}/good.zip`, GOOD_SHA, path.join(tmp, "sqlite"), LIB)).rejects.toThrow("EPERM") + expect(leftovers()).toEqual([]) + }) + + it("lets concurrent installs share one target", async () => { + const target = path.join(tmp, "sqlite") + await Promise.all([ + installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB), + installLibs(`${base}/good.zip`, GOOD_SHA, target, LIB), + ]) + expect(fs.readFileSync(path.join(target, LIB), "utf8")).toBe("lib") + expect(leftovers()).toEqual([]) + }) +}) + +describe("cli", () => { + function installWith(libsDir: string, addon: string): Promise { + setEnv("SIMPLEX_LIBS_DIR", libsDir) + setEnv("SIMPLEX_ADDON_PATH", addon) + return main(["install"]) + } + + it("parses install arguments", () => { + expect(parseInstallArgs(["install"])).toBe("sqlite") + expect(parseInstallArgs(["install", "--backend", "postgres"])).toBe("postgres") + expect(parseInstallArgs(["install", "--backend=postgres"])).toBe("postgres") + expect(() => parseInstallArgs(["install", "--backend", "mysql"])).toThrow("invalid backend: mysql") + expect(() => parseInstallArgs(["install", "--backend"])).toThrow("argument missing") + expect(() => parseInstallArgs(["install", "--force"])).toThrow("Unknown option '--force'") + expect(() => parseInstallArgs(["run"])).toThrow("expected command: install") + expect(() => parseInstallArgs(["install", "extra"])).toThrow("expected command: install") + expect(() => parseInstallArgs([])).toThrow("expected command: install") + }) + + it("prints the libs directory and the add-on", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + const addon = path.join(dir, "simplex.node") + fs.writeFileSync(libPath(dir), "lib") + fs.writeFileSync(addon, "addon") + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + try { + await expect(installWith(dir, addon)).resolves.toBe(0) + expect(log.mock.calls).toEqual([[`libsimplex installed at: ${dir}`], [`Node.js add-on installed at: ${addon}`]]) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("returns 1 without the add-on", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + fs.writeFileSync(libPath(dir), "lib") + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect(installWith(dir, path.join(dir, "simplex.node"))).resolves.toBe(1) + expect(log).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_ADDON_PATH does not exist")) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("returns 1 when install fails", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "libs-cli-")) + const addon = path.join(dir, "simplex.node") + fs.writeFileSync(addon, "addon") + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect(installWith(dir, addon)).resolves.toBe(1) + expect(error).toHaveBeenCalledWith(expect.stringContaining("install failed: SIMPLEX_LIBS_DIR has no")) + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }) + + it("installs the requested backend", async () => { + const resolve = jest.spyOn(libs, "resolveLibsDir").mockResolvedValue("/libs") + jest.spyOn(libs, "resolveAddonPath").mockResolvedValue("/addon.node") + jest.spyOn(console, "log").mockImplementation(() => {}) + await expect(main(["install", "--backend", "postgres"])).resolves.toBe(0) + expect(resolve).toHaveBeenCalledWith("postgres") + }) + + it.each(["--help", "-h"])("prints usage on %s", async flag => { + const log = jest.spyOn(console, "log").mockImplementation(() => {}) + await expect(main(["install", flag])).resolves.toBe(0) + expect(log).toHaveBeenCalledWith("usage: simplex-chat install [--backend sqlite|postgres]") + }) + + it("returns 2 with usage on invalid arguments", async () => { + const error = jest.spyOn(console, "error").mockImplementation(() => {}) + await expect(main(["install", "--backend", "mysql"])).resolves.toBe(2) + expect(error).toHaveBeenCalledWith("invalid backend: mysql\nusage: simplex-chat install [--backend sqlite|postgres]") + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/loader.test.ts b/packages/simplex-chat-nodejs/tests/loader.test.ts new file mode 100644 index 0000000000..6a03fe8feb --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/loader.test.ts @@ -0,0 +1,107 @@ +import {spawnSync} from "child_process" +import * as path from "path" + +jest.mock("../src/simplex", () => ({load: jest.fn()})) +jest.mock("../src/libs", () => ({libPath: jest.requireActual("../src/libs").libPath, resolveLibsDir: jest.fn(), resolveAddonPath: jest.fn()})) + +const PACKAGE_DIR = path.join(__dirname, "..") +const ADDON = path.join(PACKAGE_DIR, "build", "Release", "simplex.node") +const CHILD_TIMEOUT_MS = 10000 +const LIBS_DIR = path.resolve("libs") +const SWITCH_ERROR = "libsimplex already loaded with backend=sqlite; cannot switch to postgres in the same process" +const ADDON_PATH = path.resolve("simplex.node") +const {libPath} = jest.requireActual("../src/libs") + +function runAddon(script: string): string { + const r = spawnSync(process.execPath, ["-e", `const simplex = require(${JSON.stringify(ADDON)});\n${script}`], {encoding: "utf8", timeout: CHILD_TIMEOUT_MS}) + return `${r.stdout}${r.stderr}status=${r.status} signal=${r.signal}` +} + +function freshCore(): {core: typeof import("../src/core"), resolveLibsDir: jest.Mock, load: jest.Mock} { + let modules!: ReturnType + jest.isolateModules(() => { + const libs = require("../src/libs") + libs.resolveAddonPath.mockResolvedValue(ADDON_PATH) + modules = { + core: require("../src/core"), + resolveLibsDir: libs.resolveLibsDir, + load: require("../src/simplex").load, + } + }) + return modules +} + +describe("native load", () => { + it("requires load before every FFI call", () => { + const out = runAddon(` + for (const name of Object.keys(simplex).filter(n => n !== "load").sort()) { + try { simplex[name](); console.log(name + ": no error") } catch (e) { console.log(name + ": " + e.message) } + }`) + const bindings = ["chat_close_store", "chat_decrypt_file", "chat_encrypt_file", "chat_migrate_init", "chat_migrate_init_queue", + "chat_read_file", "chat_recv_msg_wait", "chat_send_cmd", "chat_write_file"] + const expected = bindings.map(name => `${name}: libsimplex is not loaded, call core.loadLibrary(backend) first\n`).join("") + expect(out).toBe(`${expected}status=0 signal=null`) + }) + + it("reports the path of a library that cannot be opened", () => { + const out = runAddon(`try { simplex.load("/nonexistent/libsimplex.so") } catch (e) { console.log(e.message) }`) + expect(out).toContain("cannot load /nonexistent/libsimplex.so") + }) + + it("rejects a non-string library path", () => { + const out = runAddon(`try { simplex.load(1) } catch (e) { console.log(e.name + ": " + e.message) }`) + expect(out).toContain("TypeError: Expected string (libPath)") + }) + + it("rejects a library without the chat exports", () => { + const out = runAddon(`try { simplex.load(${JSON.stringify(ADDON)}) } catch (e) { console.log(e.message) }`) + expect(out).toContain(`${ADDON} does not export hs_init_with_rtsopts`) + }) +}) + +describe("add-on module", () => { + it("rejects FFI calls before the add-on is loaded", () => { + const simplex = jest.requireActual("../src/simplex") + expect(() => simplex.chat_send_cmd(BigInt(1), "/v")).toThrow("libsimplex is not loaded, call core.loadLibrary(backend) first") + }) +}) + +describe("loadLibrary", () => { + it("shares one load between concurrent calls", async () => { + const {core, resolveLibsDir, load} = freshCore() + resolveLibsDir.mockResolvedValue(LIBS_DIR) + const first = core.loadLibrary("sqlite") + const second = core.loadLibrary("sqlite") + expect(second).toBe(first) + await Promise.all([first, second]) + expect(resolveLibsDir).toHaveBeenCalledTimes(1) + expect(load.mock.calls).toEqual([[ADDON_PATH, libPath(LIBS_DIR)]]) + }) + + it("refuses to switch backend while the first load is in progress", async () => { + const {core, resolveLibsDir} = freshCore() + resolveLibsDir.mockResolvedValue(LIBS_DIR) + const first = core.loadLibrary("sqlite") + await expect(core.loadLibrary("postgres")).rejects.toThrow(SWITCH_ERROR) + await first + }) + + it("keeps a completed load for the process", async () => { + const {core, resolveLibsDir, load} = freshCore() + resolveLibsDir.mockResolvedValue(LIBS_DIR) + await core.loadLibrary("sqlite") + await core.loadLibrary("sqlite") + await expect(core.loadLibrary("postgres")).rejects.toThrow(SWITCH_ERROR) + expect(resolveLibsDir).toHaveBeenCalledTimes(1) + expect(load).toHaveBeenCalledTimes(1) + }) + + it("retries after a failed load", async () => { + const {core, resolveLibsDir, load} = freshCore() + resolveLibsDir.mockRejectedValueOnce(new Error("HTTP 503")).mockResolvedValue(LIBS_DIR) + await expect(core.loadLibrary("sqlite")).rejects.toThrow("HTTP 503") + await expect(core.loadLibrary("postgres")).resolves.toBeUndefined() + expect(resolveLibsDir.mock.calls).toEqual([["sqlite"], ["postgres"]]) + expect(load).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/simplex-chat-nodejs/tests/zip.ts b/packages/simplex-chat-nodejs/tests/zip.ts new file mode 100644 index 0000000000..547c8c211c --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/zip.ts @@ -0,0 +1,47 @@ +import * as zlib from "zlib" + +const LOCAL_FILE_HEADER = 0x04034b50 +const CENTRAL_DIRECTORY_HEADER = 0x02014b50 +const END_OF_CENTRAL_DIRECTORY = 0x06054b50 +const ZIP_VERSION = 20 +const LOCAL_HEADER_SIZE = 30 +const CENTRAL_ENTRY_SIZE = 46 +const END_RECORD_SIZE = 22 + +export function storedZip(files: {[name: string]: string}): Buffer { + const local: Buffer[] = [] + const central: Buffer[] = [] + let offset = 0 + for (const [name, content] of Object.entries(files)) { + const nameBuf = Buffer.from(name) + const data = Buffer.from(content) + const crc = zlib.crc32(data) + const header = Buffer.alloc(LOCAL_HEADER_SIZE) + header.writeUInt32LE(LOCAL_FILE_HEADER, 0) + header.writeUInt16LE(ZIP_VERSION, 4) + header.writeUInt32LE(crc, 14) + header.writeUInt32LE(data.length, 18) + header.writeUInt32LE(data.length, 22) + header.writeUInt16LE(nameBuf.length, 26) + local.push(header, nameBuf, data) + const entry = Buffer.alloc(CENTRAL_ENTRY_SIZE) + entry.writeUInt32LE(CENTRAL_DIRECTORY_HEADER, 0) + entry.writeUInt16LE(ZIP_VERSION, 4) + entry.writeUInt16LE(ZIP_VERSION, 6) + entry.writeUInt32LE(crc, 16) + entry.writeUInt32LE(data.length, 20) + entry.writeUInt32LE(data.length, 24) + entry.writeUInt16LE(nameBuf.length, 28) + entry.writeUInt32LE(offset, 42) + central.push(entry, nameBuf) + offset += header.length + nameBuf.length + data.length + } + const centralBuf = Buffer.concat(central) + const end = Buffer.alloc(END_RECORD_SIZE) + end.writeUInt32LE(END_OF_CENTRAL_DIRECTORY, 0) + end.writeUInt16LE(Object.keys(files).length, 8) + end.writeUInt16LE(Object.keys(files).length, 10) + end.writeUInt32LE(centralBuf.length, 12) + end.writeUInt32LE(offset, 16) + return Buffer.concat([...local, centralBuf, end]) +} diff --git a/packages/simplex-chat-python/README.md b/packages/simplex-chat-python/README.md index b86886d591..aa892d32ab 100644 --- a/packages/simplex-chat-python/README.md +++ b/packages/simplex-chat-python/README.md @@ -56,15 +56,6 @@ ruff check && pyright && pytest tests/ Wire types under `src/simplex_chat/types/_*.py` are generated. Regenerate with `cabal test simplex-chat-test --test-options='--match Python'`. -## Release - -Manual for now. Bump `_version.py:__version__`, build a wheel, upload to PyPI: - -```bash -uv build --wheel -uv publish --token "$PYPI_TOKEN" -``` - ## License [AGPL-3.0](./LICENSE) diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py index 009ff9a1e0..009ec685f4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_native.py +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -7,6 +7,7 @@ from __future__ import annotations import ctypes import errno +import hashlib import os import platform import sys @@ -18,7 +19,7 @@ from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p from pathlib import Path from typing import Any, Literal -from ._version import LIBS_VERSION +from ._version import LIBS_RELEASE, LIBS_SHA256 Backend = Literal["sqlite", "postgres"] @@ -59,11 +60,14 @@ def _libname() -> str: return _LIBNAME[sys.platform] -def _libs_url(backend: Backend) -> str: +def _libs_asset(backend: Backend) -> str: suffix = "-postgres" if backend == "postgres" else "" + return f"simplex-chat-libs-{_platform_tag()}{suffix}.zip" + + +def _libs_url(backend: Backend) -> str: return ( - f"https://github.com/{_GITHUB_REPO}/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip" + f"https://github.com/{_GITHUB_REPO}/releases/download/{LIBS_RELEASE}/{_libs_asset(backend)}" ) @@ -84,7 +88,7 @@ def _resolve_libs_dir(backend: Backend) -> Path: "postgres backend is only supported on linux-x86_64; " f"current platform is {_platform_tag()}" ) - target = _cache_root() / f"v{LIBS_VERSION}" / backend + target = _cache_root() / LIBS_RELEASE / backend if not (target / _libname()).exists(): _download(target, backend) return target @@ -125,16 +129,21 @@ def _download(target: Path, backend: Backend) -> None: what we need and proceed. NEVER rmtree the target: that creates a TOCTOU window where another process is reading/loading the file we're deleting. """ + asset = _libs_asset(backend) + sha256 = LIBS_SHA256.get(asset) + if sha256 is None: + raise RuntimeError(f"no SHA-256 is pinned for {asset}") target.parent.mkdir(parents=True, exist_ok=True) url = _libs_url(backend) print( - f"Downloading libsimplex ({_platform_tag()}, {backend}) v{LIBS_VERSION} from {url} ...", + f"Downloading libsimplex ({_platform_tag()}, {backend}) {LIBS_RELEASE} from {url} ...", file=sys.stderr, flush=True, ) with tempfile.TemporaryDirectory(dir=target.parent) as tmp: zip_path = Path(tmp) / "libs.zip" _stream_to_file(url, zip_path, timeout=60.0) + _verify_sha256(zip_path, sha256, url) with zipfile.ZipFile(zip_path) as zf: zf.extractall(tmp) # zip layout: /libs/libsimplex.* + libHS*.* @@ -158,6 +167,16 @@ def _download(target: Path, backend: Backend) -> None: ) from e +def _verify_sha256(file: Path, expected: str, url: str) -> None: + digest = hashlib.sha256() + with file.open("rb") as f: + while chunk := f.read(_DOWNLOAD_CHUNK): + digest.update(chunk) + actual = digest.hexdigest() + if actual != expected: + raise RuntimeError(f"SHA-256 of {url} is {actual}, expected {expected}") + + _lock = threading.Lock() _lib: ctypes.CDLL | None = None _libc: ctypes.CDLL | None = None @@ -230,8 +249,8 @@ def lib_for(backend: Backend) -> ctypes.CDLL: if _lib is not None: if _backend != backend: raise RuntimeError( - f"libsimplex already loaded with backend={_backend!r}; " - f"cannot switch to {backend!r} in the same process" + f"libsimplex already loaded with backend={_backend}; " + f"cannot switch to {backend} in the same process" ) return _lib libs_dir = _resolve_libs_dir(backend) diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index a363489b3b..e5075096d4 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -1,9 +1,16 @@ -"""Single source of truth for both the Python package version and the -simplex-chat-libs release tag we depend on. +"""Package version and the simplex-chat-libs release it loads. -Bump both together for normal releases. For wrapper-only fixes use a PEP 440 -post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. +Written by scripts/ci/packages_release.py prepare: it bumps the version +and pins new library releases with the SHA-256 of their assets. """ -__version__ = "7.1.0b5" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.1.0-beta.5" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.1.0b6.post1" # PEP 440 — read by hatchling for wheel metadata +LIBS_RELEASE = "core-7.1.0.12" # simplex-chat-libs release tag +LIBS_SHA256 = { + "simplex-chat-libs-linux-aarch64.zip": "924b8e14eaba5e309fa584c4bfe598421cf64983d63790c1c45247f592f60055", + "simplex-chat-libs-linux-x86_64-postgres.zip": "323f908b45dde751d6082f8885d07d81fba8a5ab8b2df7d3b8ab3a87e56299e5", + "simplex-chat-libs-linux-x86_64.zip": "995409e963db408bcf896361b669944484f845b6744fefe479aebb0ca2e7c5cd", + "simplex-chat-libs-macos-aarch64.zip": "64464aa48b996e5a96b4aa651bc57bc69e412d3c3804b589c67bd9dc52faddbf", + "simplex-chat-libs-macos-x86_64.zip": "94664f96ff7a2cd5a419096e7e843928faa31cb3addb8869fa1e4b5a0d0b3629", + "simplex-chat-libs-windows-x86_64.zip": "2e10844e5c119921139c373ee2971341aceef7dc4198429d3be6b1570a009d15", +} diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py index 876230ef39..62dec42292 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -351,6 +351,23 @@ class ChatApi: return r["member"] raise ChatCommandError("error adding member", r) + async def api_add_member_via_group( + self, group_id: int, via_group_id: int, group_member_id: int, member_role: T.GroupMemberRole + ) -> T.GroupMember: + r = await self.send_chat_cmd( + CC.APIAddMemberViaGroup_cmd_string( + { + "groupId": group_id, + "viaGroupId": via_group_id, + "groupMemberId": group_member_id, + "memberRole": member_role, + } + ) + ) + if r["type"] == "sentGroupInvitationViaGroup": + return r["member"] + raise ChatCommandError("error adding member", r) + async def api_join_group(self, group_id: int) -> T.GroupInfo: r = await self.send_chat_cmd(CC.APIJoinGroup_cmd_string({"groupId": group_id})) if r["type"] == "userAcceptedGroupSent": diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index 365eff7e10..9dc3a05e8d 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -235,6 +235,21 @@ def APIAddMember_cmd_string(self: APIAddMember) -> str: APIAddMember_Response = CR.SentGroupInvitation | CR.ChatCmdError +# Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group. +# Network usage: interactive. +class APIAddMemberViaGroup(TypedDict): + groupId: int # int64 + viaGroupId: int # int64 + groupMemberId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APIAddMemberViaGroup_cmd_string(self: APIAddMemberViaGroup) -> str: + return '/_add #' + str(self['groupId']) + ' #' + str(self['viaGroupId']) + ' ' + str(self['groupMemberId']) + ' ' + str(self['memberRole']) + +APIAddMemberViaGroup_Response = CR.SentGroupInvitationViaGroup | CR.ChatCmdError + + # Join group. # Network usage: interactive. class APIJoinGroup(TypedDict): diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_events.py b/packages/simplex-chat-python/src/simplex_chat/types/_events.py index bd73637ef8..13f6d5cb6e 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_events.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_events.py @@ -84,6 +84,15 @@ class ReceivedGroupInvitation(TypedDict): fromMemberRole: "T.GroupMemberRole" memberRole: "T.GroupMemberRole" +class ReceivedGroupInvitationViaGroup(TypedDict): + type: Literal["receivedGroupInvitationViaGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + viaGroupInfo: "T.GroupInfo" + viaMember: "T.GroupMember" + fromMemberRole: "T.GroupMemberRole" + memberRole: "T.GroupMemberRole" + class UserJoinedGroup(TypedDict): type: Literal["userJoinedGroup"] user: "T.User" @@ -352,6 +361,7 @@ ChatEvent = ( | GroupChatItemsDeleted | ChatItemsStatusesUpdated | ReceivedGroupInvitation + | ReceivedGroupInvitationViaGroup | UserJoinedGroup | GroupUpdated | JoinedGroupMember @@ -394,7 +404,7 @@ ChatEvent = ( | ChatErrors ) -ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "remoteCtrlSessionCode", "remoteCtrlStopped", "messageError", "chatError", "chatErrors"] +ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "receivedGroupInvitationViaGroup", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "remoteCtrlSessionCode", "remoteCtrlStopped", "messageError", "chatError", "chatErrors"] class OnEventDecorator(Protocol): @@ -483,6 +493,12 @@ class OnEventDecorator(Protocol): Callable[["ReceivedGroupInvitation"], Awaitable[None]], ]: ... + @overload + def __call__(self, event: Literal["receivedGroupInvitationViaGroup"], /) -> Callable[ + [Callable[["ReceivedGroupInvitationViaGroup"], Awaitable[None]]], + Callable[["ReceivedGroupInvitationViaGroup"], Awaitable[None]], + ]: ... + @overload def __call__(self, event: Literal["userJoinedGroup"], /) -> Callable[ [Callable[["UserJoinedGroup"], Awaitable[None]]], diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py index e3885fc16a..07c7c04f49 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py @@ -264,6 +264,14 @@ class SentGroupInvitation(TypedDict): contact: "T.Contact" member: "T.GroupMember" +class SentGroupInvitationViaGroup(TypedDict): + type: Literal["sentGroupInvitationViaGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + viaGroupInfo: "T.GroupInfo" + viaMember: "T.GroupMember" + class SentInvitation(TypedDict): type: Literal["sentInvitation"] user: "T.User" @@ -402,6 +410,7 @@ ChatResponse = ( | RemoteCtrlConnecting | SentConfirmation | SentGroupInvitation + | SentGroupInvitationViaGroup | SentInvitation | SentInvitationToContact | ServiceReplyAccepted @@ -420,4 +429,4 @@ ChatResponse = ( | ApiChats ) -ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "remoteCtrlConnected", "remoteCtrlConnecting", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sentInvitationToContact", "serviceReplyAccepted", "sndFileCancelled", "startedConnectionToContact", "startedConnectionToGroup", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] +ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "remoteCtrlConnected", "remoteCtrlConnecting", "sentConfirmation", "sentGroupInvitation", "sentGroupInvitationViaGroup", "sentInvitation", "sentInvitationToContact", "serviceReplyAccepted", "sndFileCancelled", "startedConnectionToContact", "startedConnectionToGroup", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index ab06a24f53..5457f9c154 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -188,6 +188,9 @@ class BadgeProof(TypedDict): class BadgeRedeemError_invalidCode(TypedDict): type: Literal["invalidCode"] +class BadgeRedeemError_invalidReceipt(TypedDict): + type: Literal["invalidReceipt"] + class BadgeRedeemError_serviceNotConfigured(TypedDict): type: Literal["serviceNotConfigured"] @@ -210,6 +213,7 @@ class BadgeRedeemError_credentialNotVerified(TypedDict): BadgeRedeemError = ( BadgeRedeemError_invalidCode + | BadgeRedeemError_invalidReceipt | BadgeRedeemError_serviceNotConfigured | BadgeRedeemError_badgeActive | BadgeRedeemError_serviceError @@ -218,14 +222,85 @@ BadgeRedeemError = ( | BadgeRedeemError_credentialNotVerified ) -BadgeRedeemError_Tag = Literal["invalidCode", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] +BadgeRedeemError_Tag = Literal["invalidCode", "invalidReceipt", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] -BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "internal"] +BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "provider_not_configured", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "test_purchase", "internal"] BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] BadgeType = Literal["supporter", "legend", "investor"] +class BlockToken_lineBreak(TypedDict): + type: Literal["lineBreak"] + +class BlockToken_softBreak(TypedDict): + type: Literal["softBreak"] + +class BlockToken_hardBreak(TypedDict): + type: Literal["hardBreak"] + +class BlockToken_paragraphBreak(TypedDict): + type: Literal["paragraphBreak"] + lineBreaks: int # int + +class BlockToken_indent(TypedDict): + type: Literal["indent"] + +class BlockToken_headingMark(TypedDict): + type: Literal["headingMark"] + level: int # int + +class BlockToken_headingClose(TypedDict): + type: Literal["headingClose"] + +class BlockToken_headingUnderline(TypedDict): + type: Literal["headingUnderline"] + level: int # int + +class BlockToken_listMark(TypedDict): + type: Literal["listMark"] + number: NotRequired[str] + +class BlockToken_quoteMark(TypedDict): + type: Literal["quoteMark"] + +class BlockToken_fenceMark(TypedDict): + type: Literal["fenceMark"] + language: NotRequired[str] + +class BlockToken_rowMark(TypedDict): + type: Literal["rowMark"] + +class BlockToken_cellMark(TypedDict): + type: Literal["cellMark"] + +class BlockToken_delimiterRow(TypedDict): + type: Literal["delimiterRow"] + columns: list["TableAlign"] + +class BlockToken_ruleMark(TypedDict): + type: Literal["ruleMark"] + +BlockToken = ( + BlockToken_lineBreak + | BlockToken_softBreak + | BlockToken_hardBreak + | BlockToken_paragraphBreak + | BlockToken_indent + | BlockToken_headingMark + | BlockToken_headingClose + | BlockToken_headingUnderline + | BlockToken_listMark + | BlockToken_quoteMark + | BlockToken_fenceMark + | BlockToken_rowMark + | BlockToken_cellMark + | BlockToken_delimiterRow + | BlockToken_ruleMark +) + +BlockToken_Tag = Literal["lineBreak", "softBreak", "hardBreak", "paragraphBreak", "indent", "headingMark", "headingClose", "headingUnderline", "listMark", "quoteMark", "fenceMark", "rowMark", "cellMark", "delimiterRow", "ruleMark"] + class BlockingInfo(TypedDict): reason: "BlockingReason" notice: NotRequired["ClientNotice"] @@ -688,6 +763,7 @@ class CIQuote(TypedDict): sentAt: str # ISO-8601 timestamp content: "MsgContent" formattedText: NotRequired[list["FormattedText"]] + markdownBlocks: NotRequired[list["MarkdownBlock"]] # non-empty class CIReaction(TypedDict): chatDir: "CIDirection" @@ -1220,6 +1296,7 @@ class ChatItem(TypedDict): content: "CIContent" mentions: dict[str, "CIMention"] formattedText: NotRequired[list["FormattedText"]] + markdownBlocks: NotRequired[list["MarkdownBlock"]] # non-empty quotedItem: NotRequired["CIQuote"] reactions: list["CIReactionCount"] file: NotRequired["CIFile"] @@ -1243,7 +1320,7 @@ ChatListQuery = ChatListQuery_filters | ChatListQuery_search ChatListQuery_Tag = Literal["filters", "search"] -ChatPeerType = Literal["human", "bot", "business"] +ChatPeerType = Literal["human", "bot", "business", "agent"] # Used in API commands. Chat scope can only be passed with groups. @@ -1791,8 +1868,11 @@ class FileTransferMeta(TypedDict): FileType = Literal["normal", "roster"] +# The format markup is a token of block Markdown syntax; its text is the syntax. The format escape is the backslash of a backslash escape. Bold singleStar: written with single stars. + class Format_bold(TypedDict): type: Literal["bold"] + singleStar: bool class Format_italic(TypedDict): type: Literal["italic"] @@ -1846,6 +1926,13 @@ class Format_email(TypedDict): class Format_phone(TypedDict): type: Literal["phone"] +class Format_escape(TypedDict): + type: Literal["escape"] + +class Format_markup(TypedDict): + type: Literal["markup"] + token: "BlockToken" + Format = ( Format_bold | Format_italic @@ -1862,9 +1949,11 @@ Format = ( | Format_mention | Format_email | Format_phone + | Format_escape + | Format_markup ) -Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone"] +Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "simplexName", "command", "mention", "email", "phone", "escape", "markup"] class FormattedText(TypedDict): format: NotRequired["Format"] @@ -2187,6 +2276,11 @@ class LinkPreview(TypedDict): image: str content: NotRequired["LinkContent"] +class ListEntry(TypedDict): + number: NotRequired[str] + content: "SegmentRange" + items: list["ListEntry"] + class LocalBadge(TypedDict): badge: "BadgeInfo" status: "BadgeStatus" @@ -2206,6 +2300,50 @@ class LocalProfile(TypedDict): contactDomain: NotRequired["SimplexDomainClaim"] contactDomainVerified: NotRequired[bool] +class MarkdownBlock_paragraph(TypedDict): + type: Literal["paragraph"] + content: "SegmentRange" + +class MarkdownBlock_heading(TypedDict): + type: Literal["heading"] + level: int # int + content: "SegmentRange" + +class MarkdownBlock_codeBlock(TypedDict): + type: Literal["codeBlock"] + language: NotRequired[str] + content: "SegmentRange" + +class MarkdownBlock_quote(TypedDict): + type: Literal["quote"] + content: "SegmentRange" + +class MarkdownBlock_list(TypedDict): + type: Literal["list"] + items: list["ListEntry"] # non-empty + +class MarkdownBlock_table(TypedDict): + type: Literal["table"] + columns: list["TableAlign"] # non-empty + header: list["SegmentRange"] + rows: list[list["SegmentRange"]] + +class MarkdownBlock_rule(TypedDict): + type: Literal["rule"] + mark: "SegmentRange" + +MarkdownBlock = ( + MarkdownBlock_paragraph + | MarkdownBlock_heading + | MarkdownBlock_codeBlock + | MarkdownBlock_quote + | MarkdownBlock_list + | MarkdownBlock_table + | MarkdownBlock_rule +) + +MarkdownBlock_Tag = Literal["paragraph", "heading", "codeBlock", "quote", "list", "table", "rule"] + MemberCriteria = Literal["all"] # Connection link sent in a message - only short links are allowed. @@ -2933,6 +3071,11 @@ class SecurityCode(TypedDict): securityCode: str verifiedAt: str # ISO-8601 timestamp +SegmentRange = TypedDict("SegmentRange", { + "from": int, # int + "to": int, # int +}) + class SimplePreference(TypedDict): allow: "FeatureAllowed" @@ -3569,6 +3712,8 @@ class SupportGroupPreference(TypedDict): SwitchPhase = Literal["started", "confirmed", "secured", "completed"] +TableAlign = Literal["none", "left", "center", "right"] + class TimedMessagesGroupPreference(TypedDict): enable: "GroupFeatureEnabled" ttl: NotRequired[int] # int diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py index 6c4f2a923d..ec10879077 100644 --- a/packages/simplex-chat-python/tests/test_native_cache.py +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -1,10 +1,12 @@ +import hashlib import zipfile from pathlib import Path import pytest -from simplex_chat._native import _cache_root, _download, _resolve_libs_dir -from simplex_chat._version import LIBS_VERSION +from simplex_chat import _native +from simplex_chat._native import _cache_root, _download, _resolve_libs_dir, lib_for +from simplex_chat._version import LIBS_RELEASE def test_cache_root_linux(tmp_path, monkeypatch): @@ -42,7 +44,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch): monkeypatch.setattr("simplex_chat._native._download", fake_download) libs_dir = _resolve_libs_dir("sqlite") - assert libs_dir == tmp_path / "simplex-chat" / f"v{LIBS_VERSION}" / "sqlite" + assert libs_dir == tmp_path / "simplex-chat" / LIBS_RELEASE / "sqlite" assert called["backend"] == "sqlite" assert (libs_dir / "libsimplex.so").exists() @@ -50,7 +52,7 @@ def test_resolve_downloads_when_missing(tmp_path, monkeypatch): def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch): monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) monkeypatch.setattr("sys.platform", "linux") - cached = tmp_path / "simplex-chat" / f"v{LIBS_VERSION}" / "sqlite" + cached = tmp_path / "simplex-chat" / LIBS_RELEASE / "sqlite" cached.mkdir(parents=True) (cached / "libsimplex.so").touch() # Should NOT call _download — use the cached file. @@ -67,9 +69,8 @@ def test_postgres_on_macos_rejected(monkeypatch): _resolve_libs_dir("postgres") -def test_atomic_install(tmp_path, monkeypatch): - """Build a fake libs zip, mock _stream_to_file, verify extraction + atomic rename.""" - # Build zip: libs/libsimplex.so + libs/libHS-stub.so +def fake_libs_zip(tmp_path, monkeypatch, sha256=None) -> None: + """Serve a zip with libs/libsimplex.so + libs/libHS-stub.so, pinned with `sha256` (its own by default).""" src = tmp_path / "src" / "libs" src.mkdir(parents=True) (src / "libsimplex.so").write_text("fake-so") @@ -84,20 +85,54 @@ def test_atomic_install(tmp_path, monkeypatch): shutil.copy(zip_path, dest) + pinned = sha256 or hashlib.sha256(zip_path.read_bytes()).hexdigest() monkeypatch.setattr("simplex_chat._native._stream_to_file", fake_stream) monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + monkeypatch.setattr( + "simplex_chat._native.LIBS_SHA256", {"simplex-chat-libs-linux-x86_64.zip": pinned} + ) + +def test_atomic_install(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch) target = tmp_path / "out" _download(target, "sqlite") assert (target / "libsimplex.so").read_text() == "fake-so" assert (target / "libHS-stub.so").read_text() == "fake-hs" +def test_download_rejects_another_sha256(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch, sha256="0" * 64) + target = tmp_path / "out" + with pytest.raises(RuntimeError, match=f"expected {'0' * 64}"): + _download(target, "sqlite") + assert sorted(p.name for p in tmp_path.iterdir()) == ["fake-libs.zip", "src"] + + +def test_download_requires_pinned_sha256(tmp_path, monkeypatch): + fake_libs_zip(tmp_path, monkeypatch) + with pytest.raises( + RuntimeError, match="no SHA-256 is pinned for simplex-chat-libs-linux-x86_64-postgres.zip" + ): + _download(tmp_path / "out", "postgres") + + def test_libc_on_windows_is_ucrt(monkeypatch): loaded: list[str | None] = [] monkeypatch.setattr("sys.platform", "win32") monkeypatch.setattr("ctypes.CDLL", lambda name: loaded.append(name)) - from simplex_chat import _native - _native._load_libc() assert loaded == ["ucrtbase"] + + +def test_lib_for_rejects_backend_switch(monkeypatch): + monkeypatch.setattr(_native, "_lib", object()) + monkeypatch.setattr(_native, "_backend", "sqlite") + monkeypatch.setattr( + _native, "_resolve_libs_dir", lambda _: pytest.fail("must not resolve libs") + ) + with pytest.raises(RuntimeError) as exc: + lib_for("postgres") + assert str(exc.value) == ( + "libsimplex already loaded with backend=sqlite; cannot switch to postgres in the same process" + ) diff --git a/packages/simplex-chat-python/tests/test_native_url.py b/packages/simplex-chat-python/tests/test_native_url.py index 12270c9db1..f1d60d5170 100644 --- a/packages/simplex-chat-python/tests/test_native_url.py +++ b/packages/simplex-chat-python/tests/test_native_url.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest from simplex_chat._native import _libname, _libs_url, _platform_tag -from simplex_chat._version import LIBS_VERSION +from simplex_chat._version import LIBS_RELEASE @patch("sys.platform", "linux") @@ -45,7 +45,7 @@ def test_url_sqlite(_): assert ( _libs_url("sqlite") == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-linux-x86_64.zip" + f"{LIBS_RELEASE}/simplex-chat-libs-linux-x86_64.zip" ) @@ -54,5 +54,5 @@ def test_url_postgres(_): assert ( _libs_url("postgres") == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" - f"v{LIBS_VERSION}/simplex-chat-libs-linux-x86_64-postgres.zip" + f"{LIBS_RELEASE}/simplex-chat-libs-linux-x86_64-postgres.zip" ) diff --git a/packages/simplex-chat-webrtc/README.md b/packages/simplex-chat-webrtc/README.md index b8db78dbb1..087a7e6fef 100644 --- a/packages/simplex-chat-webrtc/README.md +++ b/packages/simplex-chat-webrtc/README.md @@ -4,3 +4,20 @@ npm i npm run build ``` + +## Tests + +End-to-end tests run two headless Chromium peers with `call.js` and simulate network outages by relaying media via UDP sockets controlled by the test: + +``` +npx playwright-core install chromium +npm test +``` + +Set `CHROME` to use another Chromium executable, and `VERBOSE=1` to log signaling and connection states. + +Full-stack tests in `test/fullstack.test.js` also pass call signaling via two chat cores and a local SMP server. They run when `SIMPLEX_CHAT` and `SMP_SERVER` are set to `simplex-chat` and `smp-server` executables: + +``` +SIMPLEX_CHAT=path/to/simplex-chat SMP_SERVER=path/to/smp-server npm test +``` diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index a6598b6ce7..e49a1484c2 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -8,7 +8,7 @@ "dist" ], "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "tsc && node --test test/*.test.js", "build": "prettier --write --ignore-unknown . && tsc && ./copy" }, "repository": { @@ -31,6 +31,7 @@ "husky": "^7.0.4", "isomorphic-webcrypto": "^2.3.8", "lint-staged": "^12.4.1", + "playwright-core": "^1.64.0", "prettier": "^2.6.2", "typescript": "^4.6.4" }, diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 8441560013..aa207939e8 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -142,6 +142,7 @@ interface WCallAnswer extends IWCallCommand, IWCallResponse { interface WCallIceCandidates extends IWCallCommand, IWCallResponse { type: "ice" iceCandidates: string // JSON strings for RTCIceCandidateInit[] + session?: string // JSON string for RTCSessionDescriptionInit of ICE restart offer or answer } interface WCEnableMedia extends IWCallCommand { @@ -281,6 +282,7 @@ interface Call { // controls whether transceiver's track was replaced already or set initially (in video call) cameraTrackWasSetBefore: boolean peerHasOldVersion: boolean + receiveRestartSession: (session: string, iceCandidates: RTCIceCandidateInit[]) => Promise } interface NotConnectedCall { @@ -306,6 +308,8 @@ let inactiveCallMediaSources: CallMediaSources = { let activeCall: Call | undefined let notConnectedCall: NotConnectedCall | undefined let answerTimeout = 30_000 +let reconnectTimeout = 30_000 +let iceRestartInterval = 10_000 var useWorker = false var isDesktop = false var localizedState = "" @@ -357,7 +361,8 @@ const processCommand = (function () { return { peerConnectionConfig: { iceServers: iceServers ?? defaultIceServers, - iceCandidatePoolSize: 10, + // Chrome gathers no candidates on ICE restart when candidates pool is used + iceCandidatePoolSize: 0, encodedInsertableStreams, iceTransportPolicy: relay ? "relay" : "all", // needed for Android WebView >= 69 && <= 72 where default was "plan-b" which is incompatible with transceivers @@ -486,10 +491,14 @@ const processCommand = (function () { layout: notConnectedCall?.layout ?? LayoutType.Default, cameraTrackWasSetBefore: localStream.getVideoTracks().length > 0, peerHasOldVersion: false, + receiveRestartSession, } localOrPeerMediaSourcesChanged(call) await setupMediaStreams(call) let connectionTimeout: number | undefined = setTimeout(connectionHandler, answerTimeout) + let reconnectingTimeout: number | undefined + let iceRestartTimeout: number | undefined + let wasConnected = false if (pc.connectionState) { pc.addEventListener("connectionstatechange", connectionStateChange) } else { @@ -500,33 +509,45 @@ const processCommand = (function () { async function connectionStateChange() { // "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView) // See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70) - if (pc.connectionState !== "failed") connectionHandler() + if (pc.connectionState !== "failed" || wasConnected) connectionHandler() } - async function connectionHandler() { + async function connectionHandler(reconnectTimedOut = false) { + let state: string = + pc.connectionState ?? + (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" + ? pc.iceConnectionState + : pc.iceConnectionState == "completed" + ? "connected" + : "connecting") /* webView 69-70 doesn't have connectionState yet */ + if (wasConnected) { + if (reconnectTimedOut) { + reconnectingTimeout = undefined + if (activeCall !== call || state == "connected") return + state = "disconnected" + } else if (state == "disconnected" || state == "failed") { + if (reconnectingTimeout !== undefined) return + reconnectingTimeout = setTimeout(() => connectionHandler(true), reconnectTimeout) + if (isIceRestarter()) restartIce() + state = "reconnecting" + } else if (state == "connecting" || state == "new") { + return + } + } sendMessageToNative({ resp: { type: "connection", state: { - connectionState: - pc.connectionState ?? - (pc.iceConnectionState != "completed" && pc.iceConnectionState != "checking" - ? pc.iceConnectionState - : pc.iceConnectionState == "completed" - ? "connected" - : "connecting") /* webView 69-70 doesn't have connectionState yet */, + connectionState: state, iceConnectionState: pc.iceConnectionState, iceGatheringState: pc.iceGatheringState, signalingState: pc.signalingState, }, }, }) - if ( - pc.connectionState == "disconnected" || - pc.connectionState == "failed" || - (!pc.connectionState && (pc.iceConnectionState == "disconnected" || pc.iceConnectionState == "failed")) - ) { + if (state == "disconnected" || state == "failed") { clearConnectionTimeout() + clearReconnecting() if (pc.connectionState) { pc.removeEventListener("connectionstatechange", connectionStateChange) } else { @@ -536,8 +557,10 @@ const processCommand = (function () { setTimeout(() => sendMessageToNative({resp: {type: "ended"}}), 0) } endCall() - } else if (pc.connectionState == "connected" || (!pc.connectionState && pc.iceConnectionState == "connected")) { + } else if (state == "connected") { clearConnectionTimeout() + clearReconnecting() + wasConnected = true const stats = (await pc.getStats()) as Map for (const stat of stats.values()) { const {type, state} = stat @@ -564,6 +587,51 @@ const processCommand = (function () { connectionTimeout = undefined } } + + function clearReconnecting() { + clearTimeout(reconnectingTimeout) + reconnectingTimeout = undefined + clearTimeout(iceRestartTimeout) + iceRestartTimeout = undefined + } + + // Only the party that sent the initial offer restarts ICE, so that restart offers never collide + function isIceRestarter(): boolean { + return pc.localDescription?.type == "offer" + } + + async function restartIce() { + if (activeCall !== call || reconnectingTimeout === undefined) return + iceRestartTimeout = setTimeout(restartIce, iceRestartInterval) + // the previous restart offer is not answered yet + if (pc.signalingState != "stable") return + try { + const iceCandidates = getIceCandidates(pc, config) + const offer = await pc.createOffer({iceRestart: true}) + await pc.setLocalDescription(offer) + sendMessageToNative({resp: {type: "ice", session: serialize(offer), iceCandidates: await iceCandidates}}) + } catch (e) { + console.log("ICE restart error", e) + } + } + + async function receiveRestartSession(session: string, iceCandidates: RTCIceCandidateInit[]) { + const desc: RTCSessionDescriptionInit = parse(session) + const remoteDesc = new RTCSessionDescription(!webView69Or70() ? desc : adaptSdpToOldWebView(desc)) + if (desc.type == "offer" && !isIceRestarter()) { + await pc.setRemoteDescription(remoteDesc) + const localIceCandidates = getIceCandidates(pc, config) + const answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + addIceCandidates(pc, iceCandidates) + sendMessageToNative({resp: {type: "ice", session: serialize(answer), iceCandidates: await localIceCandidates}}) + } else if (desc.type == "answer" && pc.signalingState == "have-local-offer") { + await pc.setRemoteDescription(remoteDesc) + addIceCandidates(pc, iceCandidates) + } else { + throw new Error(`unexpected ICE restart ${desc.type} in ${pc.signalingState} state`) + } + } } function serialize(x: T): string { @@ -729,7 +797,10 @@ const processCommand = (function () { break case "ice": const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - if (pc) { + if (activeCall && command.session) { + await activeCall.receiveRestartSession(command.session, remoteIceCandidates) + resp = {type: "ok"} + } else if (pc) { addIceCandidates(pc, remoteIceCandidates) resp = {type: "ok"} } else { diff --git a/packages/simplex-chat-webrtc/test/chat.js b/packages/simplex-chat-webrtc/test/chat.js new file mode 100644 index 0000000000..bf35f775d7 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/chat.js @@ -0,0 +1,147 @@ +// Runs a local SMP server and chat cores with WebSocket API, so that call signaling goes via the chat protocol. +const {spawn, execFileSync} = require("child_process") +const fs = require("fs") +const net = require("net") +const path = require("path") +const {sleep, log} = require("./harness") + +const START_TIMEOUT = 30000 + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.on("error", reject) + server.listen(0, "127.0.0.1", () => { + const {port} = server.address() + server.close(() => resolve(port)) + }) + }) +} + +async function waitUntil(what, check) { + const deadline = Date.now() + START_TIMEOUT + while (!(await check())) { + if (Date.now() > deadline) throw new Error(`${what} did not start in ${START_TIMEOUT}ms`) + await sleep(200) + } +} + +function canConnect(port) { + return new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1", () => { + socket.destroy() + resolve(true) + }) + socket.on("error", () => resolve(false)) + }) +} + +function spawnLogged(name, bin, args, env) { + const proc = spawn(bin, args, {env, stdio: ["ignore", "pipe", "pipe"]}) + const output = (data) => log(`${name}:`, data.toString().trimEnd()) + proc.stdout.on("data", output) + proc.stderr.on("data", output) + return proc +} + +async function startSmpServer(bin, dir) { + const env = {...process.env, SMP_SERVER_CFG_PATH: path.join(dir, "cfg"), SMP_SERVER_LOG_PATH: path.join(dir, "log")} + const init = ["init", "--ip", "127.0.0.1", "--no-password", "--disable-web", "--disable-store-log", "-y"] + const fingerprint = execFileSync(bin, init, {env, stdio: ["ignore", "pipe", "pipe"]}) + .toString() + .match(/Fingerprint: (\S+)/)[1] + const port = await freePort() + const iniFile = path.join(dir, "cfg", "smp-server.ini") + const ini = fs + .readFileSync(iniFile, "utf8") + .replace(/^port = .*$/m, `port = ${port}`) + .replace(/^host = .*$/m, "host = localhost") + fs.writeFileSync(iniFile, ini) + const proc = spawnLogged("smp-server", bin, ["start"], env) + await waitUntil("SMP server", () => canConnect(port)) + return {address: `smp://${fingerprint}@localhost:${port}`, stop: () => proc.kill()} +} + +class ChatClient { + constructor(name, proc, ws) { + this.name = name + this.proc = proc + this.ws = ws + this.corrId = 0 + this.pending = new Map() + this.events = [] + this.waiters = [] + this.onEvent = async () => {} + ws.addEventListener("message", (e) => this.receive(JSON.parse(e.data))) + } + + static async start(bin, dir, name, smpServer) { + const port = await freePort() + const args = ["-d", path.join(dir, name), "-s", smpServer, "-p", String(port), "--user-display-name", name] + const proc = spawnLogged(`chat ${name}`, bin, args, process.env) + await waitUntil(`chat ${name}`, () => canConnect(port)) + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + await new Promise((resolve, reject) => { + ws.addEventListener("open", resolve, {once: true}) + ws.addEventListener("error", reject, {once: true}) + }) + return new ChatClient(name, proc, ws) + } + + receive({corrId, resp}) { + if (corrId) { + this.pending.get(corrId)?.(resp) + this.pending.delete(corrId) + return + } + log(`${this.name} event:`, resp.type) + this.events.push(resp) + this.onEvent(resp).catch((e) => log(`${this.name} event error`, e)) + this.waiters = this.waiters.filter((w) => !(w.pred(resp) && (w.resolve(resp), true))) + } + + async cmd(cmd) { + const corrId = String(++this.corrId) + log(`${this.name} cmd:`, cmd.slice(0, 100)) + const resp = await new Promise((resolve) => { + this.pending.set(corrId, resolve) + this.ws.send(JSON.stringify({corrId, cmd})) + }) + if (resp.type === "chatCmdError") throw new Error(`${this.name} ${cmd.split(" ", 2).join(" ")}: ${JSON.stringify(resp)}`) + return resp + } + + waitEvent(pred, timeout, what) { + const found = this.events.find(pred) + if (found) return Promise.resolve(found) + return new Promise((resolve, reject) => { + const waiter = { + pred, + resolve: (resp) => { + clearTimeout(timer) + resolve(resp) + }, + } + this.waiters.push(waiter) + const timer = setTimeout(() => { + this.waiters = this.waiters.filter((w) => w !== waiter) + reject(new Error(`${this.name}: no ${what} event in ${timeout}ms`)) + }, timeout) + }) + } + + stop() { + this.ws.close() + this.proc.kill() + } +} + +async function connectContacts(alice, bob) { + const inv = await alice.cmd("/c") + await bob.cmd(`/c ${inv.connLinkInvitation.connFullLink}`) + const connected = (c) => c.waitEvent((e) => e.type === "contactConnected", START_TIMEOUT, "contactConnected") + const [a, b] = await Promise.all([connected(alice), connected(bob)]) + return [a.contact.contactId, b.contact.contactId] +} + +module.exports = {startSmpServer, ChatClient, connectContacts} diff --git a/packages/simplex-chat-webrtc/test/fullstack.test.js b/packages/simplex-chat-webrtc/test/fullstack.test.js new file mode 100644 index 0000000000..06711845a3 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/fullstack.test.js @@ -0,0 +1,188 @@ +// Calls between two chat cores connected via a local SMP server, with the test playing the native app: +// it passes messages between call.js and the core API as the Android and desktop apps do. +const {test} = require("node:test") +const assert = require("node:assert/strict") +const fs = require("fs") +const os = require("os") +const path = require("path") +const {openPeers, sleep} = require("./harness") +const {startSmpServer, ChatClient, connectContacts} = require("./chat") + +const {SIMPLEX_CHAT, SMP_SERVER} = process.env +const MEDIA = "audio" +const DISCONNECT_DETECTION = 10000 +const RECONNECT = 20000 +const SIGNALING = 15000 +const CORE_CALL_STATUSES = ["connecting", "connected", "disconnected", "failed"] + +async function withChatCall({useWorker = false}, run) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "simplex-call-test-")) + const stops = [] + try { + const smp = await startSmpServer(SMP_SERVER, path.join(dir, "smp")) + stops.push(smp.stop) + const alice = await ChatClient.start(SIMPLEX_CHAT, dir, "alice", smp.address) + stops.push(() => alice.stop()) + const bob = await ChatClient.start(SIMPLEX_CHAT, dir, "bob", smp.address) + stops.push(() => bob.stop()) + const [aliceContact, bobContact] = await connectContacts(alice, bob) + const peers = await openPeers({useWorker}) + stops.push(peers.close) + // alice calls bob: a is alice's call.js, b is bob's call.js + const call = {...peers, alice, bob, aliceContact, bobContact} + connectApp(call.a, alice, aliceContact, peers) + connectApp(call.b, bob, bobContact, peers) + await startCall(call) + await run(call) + } finally { + for (const stop of stops.reverse()) await stop() + fs.rmSync(dir, {recursive: true, force: true}) + } +} + +function connectApp(peer, chat, contactId, {network, signaling}) { + peer.onResponse = async (resp) => { + switch (resp.type) { + case "ice": { + const {session, iceCandidates} = await network.routeSignal(peer.name, resp.session, resp.iceCandidates) + const extraInfo = JSON.stringify({rtcSession: session, rtcIceCandidates: iceCandidates}) + signaling.send(() => chat.cmd(`/_call extra @${contactId} ${extraInfo}`)) + break + } + case "connection": + if (CORE_CALL_STATUSES.includes(resp.state.connectionState)) { + await chat.cmd(`/_call status @${contactId} ${resp.state.connectionState}`) + } + break + case "ended": + await chat.cmd(`/_call end @${contactId}`).catch(() => {}) + break + } + } + chat.onEvent = async (event) => { + switch (event.type) { + case "callExtraInfo": + await peer.command({type: "ice", iceCandidates: event.extraInfo.rtcIceCandidates, session: event.extraInfo.rtcSession}) + break + case "callEnded": + await peer.command({type: "end"}) + break + } + } +} + +async function startCall({a, b, network, alice, bob, aliceContact, bobContact}) { + const capabilities = await a.command({type: "capabilities", media: MEDIA}) + assert.equal(capabilities.resp.type, "capabilities") + const callType = {media: MEDIA, capabilities: capabilities.resp.capabilities} + await alice.cmd(`/_call invite @${aliceContact} ${JSON.stringify(callType)}`) + + const {callInvitation} = await bob.waitEvent((e) => e.type === "callInvitation", SIGNALING, "callInvitation") + const offer = await b.command({type: "start", media: MEDIA, aesKey: callInvitation.sharedKey, iceServers: []}) + assert.equal(offer.resp.type, "offer", JSON.stringify(offer.resp)) + const offerSignal = await network.routeSignal("B", offer.resp.offer, offer.resp.iceCandidates) + const callOffer = { + callType: {media: MEDIA, capabilities: offer.resp.capabilities}, + rtcSession: {rtcSession: offerSignal.session, rtcIceCandidates: offerSignal.iceCandidates}, + } + await bob.cmd(`/_call offer @${bobContact} ${JSON.stringify(callOffer)}`) + + const offered = await alice.waitEvent((e) => e.type === "callOffer", SIGNALING, "callOffer") + const answer = await a.command({ + type: "offer", + offer: offered.offer.rtcSession, + iceCandidates: offered.offer.rtcIceCandidates, + media: MEDIA, + aesKey: offered.sharedKey, + iceServers: [], + }) + assert.equal(answer.resp.type, "answer", JSON.stringify(answer.resp)) + const answerSignal = await network.routeSignal("A", answer.resp.answer, answer.resp.iceCandidates) + const callAnswer = {rtcSession: answerSignal.session, rtcIceCandidates: answerSignal.iceCandidates} + await alice.cmd(`/_call answer @${aliceContact} ${JSON.stringify(callAnswer)}`) + + const answered = await bob.waitEvent((e) => e.type === "callAnswer", SIGNALING, "callAnswer") + const ok = await b.command({type: "answer", answer: answered.answer.rtcSession, iceCandidates: answered.answer.rtcIceCandidates}) + assert.equal(ok.resp.type, "ok", JSON.stringify(ok.resp)) + await Promise.all([a.waitForState("connected", RECONNECT), b.waitForState("connected", RECONNECT)]) +} + +async function assertAudioDecoded(a, b) { + const before = [await a.receivedAudioEnergy(), await b.receivedAudioEnergy()] + await sleep(2000) + const after = [await a.receivedAudioEnergy(), await b.receivedAudioEnergy()] + assert.ok(after[0] > before[0], `A decoded no audio: ${before[0]} -> ${after[0]}`) + assert.ok(after[1] > before[1], `B decoded no audio: ${before[1]} -> ${after[1]}`) +} + +async function callItem(chat, contactId) { + const r = await chat.cmd(`/_get chat @${contactId} count=1`) + return r.chat.chatItems[0].content +} + +function restartSessionsReceived(chat) { + return chat.events.filter((e) => e.type === "callExtraInfo" && e.extraInfo.rtcSession).length +} + +const skip = !(SIMPLEX_CHAT && SMP_SERVER) && "set SIMPLEX_CHAT and SMP_SERVER to chat CLI and SMP server executables" + +test("calls via chat core", {skip}, async (t) => { + for (const [app, useWorker] of [ + ["android", false], + ["desktop", true], + ]) { + await t.test(`${app}: encrypted call reconnects with ICE restart after network path change`, () => + withChatCall({useWorker}, async ({a, b, network, alice, bob, aliceContact, bobContact}) => { + assert.ok(a.messages.find((m) => m.resp.type === "capabilities").resp.capabilities.encryption, "call is not encrypted") + assert.equal(await a.page.evaluate(() => !!activeCall.worker), useWorker, "encryption worker") + await assertAudioDecoded(a, b) + assert.deepEqual(await callItem(alice, aliceContact), {type: "sndCall", status: "progress", duration: 0}) + const connectedAt = Date.now() + + const from = [a.messages.length, b.messages.length] + network.down = true + network.changePath() + await Promise.all([ + a.waitForState("reconnecting", DISCONNECT_DETECTION, from[0]), + b.waitForState("reconnecting", DISCONNECT_DETECTION, from[1]), + ]) + await sleep(3000) + network.down = false + await Promise.all([a.waitForState("connected", RECONNECT, from[0]), b.waitForState("connected", RECONNECT, from[1])]) + await assertAudioDecoded(a, b) + assert.ok(restartSessionsReceived(alice) > 0, "alice received no ICE restart offer") + assert.ok(restartSessionsReceived(bob) > 0, "bob received no ICE restart answer") + assert.equal((await callItem(alice, aliceContact)).status, "progress") + assert.equal((await callItem(bob, bobContact)).status, "progress") + + await alice.cmd(`/_call end @${aliceContact}`) + await a.command({type: "end"}) + await bob.waitEvent((e) => e.type === "callEnded", SIGNALING, "callEnded") + const minDuration = Math.floor((Date.now() - connectedAt) / 1000) - 1 + const aliceItem = await callItem(alice, aliceContact) + assert.equal(aliceItem.status, "ended") + assert.ok(aliceItem.duration >= minDuration, `call duration ${aliceItem.duration} < ${minDuration}`) + }) + ) + } + + await t.test("call ends in both chats when reconnection times out", () => + withChatCall({}, async ({a, b, network, alice, bob, aliceContact, bobContact}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 5000}), b.setTimeouts({reconnectTimeout: 5000})]) + network.down = true + // the first party to time out ends the call, and the other party ends it on "callEnded" event + const endedBy = (peer, other) => peer.waitForEnded(DISCONNECT_DETECTION + 10000).then(() => other) + const other = await Promise.any([endedBy(a, bob), endedBy(b, alice)]) + await other.waitEvent((e) => e.type === "callEnded", SIGNALING, "callEnded") + network.down = false + for (const [chat, contactId] of [ + [alice, aliceContact], + [bob, bobContact], + ]) { + const item = await callItem(chat, contactId) + assert.equal(item.status, "ended", `${chat.name} call item: ${JSON.stringify(item)}`) + assert.ok(item.duration >= 5, `${chat.name} call duration ${item.duration}`) + } + }) + ) +}) diff --git a/packages/simplex-chat-webrtc/test/harness.js b/packages/simplex-chat-webrtc/test/harness.js new file mode 100644 index 0000000000..d8e1668ce1 --- /dev/null +++ b/packages/simplex-chat-webrtc/test/harness.js @@ -0,0 +1,317 @@ +// Runs two headless Chromium peers with call.js; the test plays the native app and the chat core. +// Media can only flow through UDP relays controlled by the test, so outages and path changes are simulated +// by dropping packets in the relays. +const http = require("http") +const fs = require("fs") +const path = require("path") +const dgram = require("dgram") +const {chromium} = require("playwright-core") +const LZString = require("lz-string") + +const ROOT = path.join(__dirname, "..") +const PAGE_FILES = { + "/android/call.html": path.join(ROOT, "src/android/call.html"), + "/android/style.css": path.join(ROOT, "src/android/style.css"), + "/call.js": path.join(ROOT, "dist/call.js"), + "/lz-string.min.js": path.join(ROOT, "node_modules/lz-string/libs/lz-string.min.js"), +} +const CONTENT_TYPES = {".html": "text/html", ".js": "text/javascript", ".css": "text/css"} + +const startTime = Date.now() +function log(...args) { + if (process.env.VERBOSE) console.log(((Date.now() - startTime) / 1000).toFixed(1).padStart(6), ...args) +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const decode = (s) => JSON.parse(LZString.decompressFromBase64(s)) +const encode = (x) => LZString.compressToBase64(JSON.stringify(x)) + +function servePage() { + const server = http.createServer((req, res) => { + const file = PAGE_FILES[new URL(req.url, "http://localhost").pathname] + if (!file) return res.writeHead(404).end() + res.writeHead(200, {"Content-Type": CONTENT_TYPES[path.extname(file)]}).end(fs.readFileSync(file)) + }) + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(server))) +} + +function bindUdp(ip) { + return new Promise((resolve) => { + const socket = dgram.createSocket("udp4") + socket.bind(0, ip, () => resolve(socket)) + }) +} + +// Relays one ICE generation: each peer is told that the other peer is at the relay socket. +class Relay { + constructor(network, ip) { + this.network = network + this.ip = ip + this.ufrag = {A: undefined, B: undefined} + this.real = {A: undefined, B: undefined} + this.dead = false + this.forwarded = 0 + } + + async open() { + this.sockets = {A: await bindUdp(this.ip), B: await bindUdp(this.ip)} + this.sockets.A.on("message", (msg, from) => this.forward(msg, from, "B", "A")) + this.sockets.B.on("message", (msg, from) => this.forward(msg, from, "A", "B")) + } + + // socket A is advertised to B as A's address, so packets on it come from B and go to A via socket B + forward(msg, from, sender, receiver) { + if (this.dead || this.network.down) return + this.real[sender] = from + const to = this.real[receiver] + if (!to) return + this.forwarded++ + this.sockets[sender].send(msg, to.port, to.address) + } + + port(side) { + return this.sockets[side].address().port + } + + close() { + this.sockets.A.close() + this.sockets.B.close() + } +} + +class Network { + constructor() { + this.down = false + this.relays = [] + } + + // Returns the rewritten candidate, or undefined if the candidate cannot be relayed. + async route(side, candidate) { + const [, component, protocol, , ip, port, , type] = candidate.candidate.split(" ") + if (protocol.toLowerCase() !== "udp" || type !== "host" || ip.includes(":") || component !== "1") return + const ufrag = candidate.usernameFragment + if (this.relays.some((r) => r.ufrag[side] === ufrag)) return + let relay = this.relays.find((r) => !r.dead && r.ufrag[side] === undefined) + if (!relay) { + relay = new Relay(this, ip) + await relay.open() + this.relays.push(relay) + } + relay.ufrag[side] = ufrag + relay.real[side] = {address: ip, port: +port} + log(`relay ${this.relays.indexOf(relay)}: ${side} ${ip}:${port} -> ${relay.ip}:${relay.port(side)}`) + const parts = candidate.candidate.split(" ") + parts[4] = relay.ip + parts[5] = String(relay.port(side)) + return {...candidate, candidate: parts.join(" ")} + } + + // Takes and returns compressed session description and candidates, as in signaling messages + async routeSignal(side, session, iceCandidates) { + // candidates in SDP would connect the peers directly, bypassing the relays + if (session && decode(session).sdp.includes("a=candidate:")) throw new Error(`${side}: SDP has ICE candidates`) + const routed = [] + for (const c of decode(iceCandidates)) { + const r = c?.candidate && (await this.route(side, c)) + if (r) routed.push(r) + } + return {session, iceCandidates: encode(routed)} + } + + // Addresses of all current ICE generations become unreachable, as when a device switches networks. + changePath() { + for (const relay of this.relays) relay.dead = true + } + + close() { + for (const relay of this.relays) relay.close() + } +} + +class Peer { + constructor(name, page, {ignoresIceRestart = false} = {}) { + this.name = name + this.page = page + this.ignoresIceRestart = ignoresIceRestart + this.messages = [] + this.waiters = [] + this.onResponse = async () => {} + } + + async open(url) { + await this.page.exposeFunction("sendToTest", (json) => this.receive(JSON.parse(json))) + this.page.on("console", (m) => log(`${this.name} console:`, m.text().slice(0, 200))) + this.page.on("pageerror", (e) => log(`${this.name} page error:`, e.message)) + await this.page.goto(url) + await this.page.evaluate(() => { + sendMessageToNative = (msg) => window.sendToTest(JSON.stringify(msg)) + }) + } + + receive(msg) { + const resp = msg.resp + log(`${this.name} <-`, resp.type, resp.state?.connectionState ?? "", resp.session ? "with session" : "") + this.messages.push(msg) + if (!msg.command) this.onResponse(resp).catch((e) => log(`${this.name} response error`, e)) + this.waiters = this.waiters.filter((w) => !(w.pred(msg) && (w.resolve(msg), true))) + } + + connectionStates() { + return this.messages.filter((m) => m.resp.type === "connection").map((m) => m.resp.state.connectionState) + } + + sessionsSent() { + return this.messages.filter((m) => m.resp.type === "ice" && !m.command && m.resp.session).length + } + + ended() { + return this.messages.some((m) => m.resp.type === "ended") + } + + waitFor(pred, timeout, what) { + const found = this.messages.find(pred) + if (found) return Promise.resolve(found) + return new Promise((resolve, reject) => { + const waiter = { + pred, + resolve: (msg) => { + clearTimeout(timer) + resolve(msg) + }, + } + this.waiters.push(waiter) + const timer = setTimeout(() => { + this.waiters = this.waiters.filter((w) => w !== waiter) + reject(new Error(`${this.name}: no ${what} in ${timeout}ms, connection states: ${this.connectionStates()}`)) + }, timeout) + }) + } + + waitForState(state, timeout, after = 0) { + const pred = (m) => m.resp.type === "connection" && m.resp.state.connectionState === state && this.messages.indexOf(m) >= after + return this.waitFor(pred, timeout, `"${state}" state`) + } + + waitForEnded(timeout) { + return this.waitFor((m) => m.resp.type === "ended", timeout, "ended") + } + + command(command) { + log(`${this.name} ->`, command.type, command.session ? "with session" : "") + return this.page.evaluate((c) => processCommand({corrId: 0, command: c}), command) + } + + async setTimeouts(timeouts) { + await this.page.evaluate((t) => { + if (t.reconnectTimeout) reconnectTimeout = t.reconnectTimeout + if (t.iceRestartInterval) iceRestartInterval = t.iceRestartInterval + }, timeouts) + } + + async receivedBytes() { + return (await this.inboundAudio()).bytesReceived + } + + // energy grows only when received audio is decoded, i.e. it is decrypted with the right key + async receivedAudioEnergy() { + return (await this.inboundAudio()).totalAudioEnergy + } + + inboundAudio() { + return this.page.evaluate(async () => { + let audio + ;(await activeCall.connection.getStats()).forEach((s) => { + if (s.type === "inbound-rtp" && s.kind === "audio" && (!audio || s.bytesReceived > audio.bytesReceived)) audio = s + }) + return {bytesReceived: audio?.bytesReceived ?? 0, totalAudioEnergy: audio?.totalAudioEnergy ?? 0} + }) + } +} + +// Signaling stops while the network is down and resumes in order, as messages queued in SMP servers. +class Signaling { + constructor(network) { + this.network = network + this.queue = Promise.resolve() + this.closed = false + } + + send(deliver) { + this.queue = this.queue.then(async () => { + while (this.network.down && !this.closed) await sleep(100) + if (!this.closed) await deliver().catch((e) => log("signaling error", e.message)) + }) + } +} + +// useWorker encrypts media in a worker with RTCRtpScriptTransform, as the desktop app does +async function openPeers({answerer = {}, useWorker = false} = {}) { + const server = await servePage() + const url = `http://localhost:${server.address().port}/android/call.html` + const browser = await chromium.launch({ + executablePath: process.env.CHROME, + args: [ + "--use-fake-ui-for-media-stream", + "--use-fake-device-for-media-stream", + "--disable-features=WebRtcHideLocalIpsWithMdns", + "--autoplay-policy=no-user-gesture-required", + ], + }) + const newPeer = async (name, opts) => { + const context = await browser.newContext({permissions: ["camera", "microphone"]}) + const peer = new Peer(name, await context.newPage(), opts) + await peer.open(url) + if (useWorker) await peer.page.evaluate(() => (useWorker = true)) + return peer + } + // A sends the call invitation and B accepts it, so B sends the WebRTC offer, as "start" command in the apps + const a = await newPeer("A", answerer) + const b = await newPeer("B") + const network = new Network() + const signaling = new Signaling(network) + const close = async () => { + signaling.closed = true + await browser.close() + network.close() + server.close() + } + return {a, b, network, signaling, close} +} + +// The test relays signaling between the peers directly, as the apps and the chat core would do. +async function startCall({media = "audio", answerer = {}} = {}) { + const peers = await openPeers({answerer}) + const {a, b, network, signaling, close} = peers + const relayIce = (from, to) => async (resp) => { + if (resp.type !== "ice") return + const {session, iceCandidates} = await network.routeSignal(from.name, resp.session, resp.iceCandidates) + signaling.send(() => to.command({type: "ice", iceCandidates, session: to.ignoresIceRestart ? undefined : session})) + } + a.onResponse = relayIce(a, b) + b.onResponse = relayIce(b, a) + + try { + await a.command({type: "capabilities", media}) + const offer = await b.command({type: "start", media, iceServers: []}) + if (offer.resp.type !== "offer") throw new Error(`B start: ${JSON.stringify(offer.resp)}`) + const offerSignal = await network.routeSignal("B", offer.resp.offer, offer.resp.iceCandidates) + const answer = await a.command({ + type: "offer", + offer: offerSignal.session, + iceCandidates: offerSignal.iceCandidates, + media, + iceServers: [], + }) + if (answer.resp.type !== "answer") throw new Error(`A offer: ${JSON.stringify(answer.resp)}`) + const answerSignal = await network.routeSignal("A", answer.resp.answer, answer.resp.iceCandidates) + const ok = await b.command({type: "answer", answer: answerSignal.session, iceCandidates: answerSignal.iceCandidates}) + if (ok.resp.type !== "ok") throw new Error(`B answer: ${JSON.stringify(ok.resp)}`) + await Promise.all([a.waitForState("connected", 20000), b.waitForState("connected", 20000)]) + } catch (e) { + await close() + throw e + } + return peers +} + +module.exports = {openPeers, startCall, sleep, log} diff --git a/packages/simplex-chat-webrtc/test/reconnect.test.js b/packages/simplex-chat-webrtc/test/reconnect.test.js new file mode 100644 index 0000000000..d7eb9f861e --- /dev/null +++ b/packages/simplex-chat-webrtc/test/reconnect.test.js @@ -0,0 +1,95 @@ +const {test} = require("node:test") +const assert = require("node:assert/strict") +const {startCall, sleep} = require("./harness") + +// Chrome reports "disconnected" about 6 seconds after packets stop +const DISCONNECT_DETECTION = 10000 +const RECONNECT = 20000 + +async function assertMediaFlows(a, b) { + const before = [await a.receivedBytes(), await b.receivedBytes()] + await sleep(2000) + const after = [await a.receivedBytes(), await b.receivedBytes()] + assert.ok(after[0] > before[0], `A received no media: ${before[0]} -> ${after[0]}`) + assert.ok(after[1] > before[1], `B received no media: ${before[1]} -> ${after[1]}`) +} + +async function assertReconnects(a, b, outage) { + const from = [a.messages.length, b.messages.length] + await Promise.all([ + a.waitForState("reconnecting", DISCONNECT_DETECTION, from[0]), + b.waitForState("reconnecting", DISCONNECT_DETECTION, from[1]), + ]) + await outage() + await Promise.all([a.waitForState("connected", RECONNECT, from[0]), b.waitForState("connected", RECONNECT, from[1])]) + await assertMediaFlows(a, b) + assert.ok(!a.ended() && !b.ended(), "call ended") + assert.ok(!a.connectionStates().includes("disconnected"), `A states: ${a.connectionStates()}`) + assert.ok(!b.connectionStates().includes("disconnected"), `B states: ${b.connectionStates()}`) +} + +async function withCall(opts, run) { + const call = await startCall(opts) + try { + await run(call) + } finally { + await call.close() + } +} + +test("call reconnection", {concurrency: true}, async (t) => { + await t.test("recovers after a brief outage", () => + withCall({}, async ({a, b, network}) => { + await assertMediaFlows(a, b) + network.down = true + await assertReconnects(a, b, async () => { + network.down = false + }) + }) + ) + + await t.test("recovers after an outage longer than ICE consent timeout", () => + withCall({}, async ({a, b, network}) => { + network.down = true + await assertReconnects(a, b, async () => { + await sleep(15000) + network.down = false + }) + assert.ok(b.sessionsSent() > 0, "offerer did not restart ICE") + assert.ok(a.sessionsSent() > 0, "answerer did not answer ICE restart") + }) + ) + + await t.test("recovers when the network path changes", () => + withCall({}, async ({a, b, network}) => { + network.down = true + network.changePath() + await assertReconnects(a, b, async () => { + network.down = false + }) + const relay = network.relays[network.relays.length - 1] + assert.ok(!relay.dead && relay.forwarded > 0, "media does not use the new path") + }) + ) + + await t.test("ends the call when reconnection times out", () => + withCall({}, async ({a, b, network}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 5000}), b.setTimeouts({reconnectTimeout: 5000})]) + network.down = true + await Promise.all([a.waitForState("reconnecting", DISCONNECT_DETECTION), b.waitForState("reconnecting", DISCONNECT_DETECTION)]) + await Promise.all([a.waitForEnded(10000), b.waitForEnded(10000)]) + assert.deepEqual(a.connectionStates().slice(-2), ["reconnecting", "disconnected"]) + assert.deepEqual(b.connectionStates().slice(-2), ["reconnecting", "disconnected"]) + }) + ) + + await t.test("ends the call when the peer does not support ICE restart", () => + withCall({answerer: {ignoresIceRestart: true}}, async ({a, b, network}) => { + await Promise.all([a.setTimeouts({reconnectTimeout: 10000}), b.setTimeouts({reconnectTimeout: 10000})]) + network.changePath() + await Promise.all([a.waitForEnded(DISCONNECT_DETECTION + 15000), b.waitForEnded(DISCONNECT_DETECTION + 15000)]) + assert.ok(b.sessionsSent() > 0, "offerer did not restart ICE") + assert.equal(a.sessionsSent(), 0) + }) + ) +}) diff --git a/plans/2026-09-04-badge-binding-and-file-limits.md b/plans/2026-09-04-badge-binding-and-file-limits.md index 72890d8ac7..29d6905b85 100644 --- a/plans/2026-09-04-badge-binding-and-file-limits.md +++ b/plans/2026-09-04-badge-binding-and-file-limits.md @@ -11,14 +11,14 @@ The same badge raises the size limit for files the user sends. Today each app de The changes: -1. Three new presentation headers: one for a profile shown in a chat, one for a file invitation, one for a file description. The random header stays valid where there is no chat yet, and for the profile in direct chats until the direct binding reaches the handshake. -2. In p2p groups a badge is accepted only from a message signed by the member. The member connection handshake is signed in both directions, and the profile sent in it is stored, so the badge appears when two members connect. -3. In channels a badge is accepted from any profile message, because member keys there come from the roster, the member list signed by the channel owner. +1. Five new presentation headers: a profile shown in a chat, a file invitation, a file description, a contact request, and a link. The random header of released clients stays accepted until v7.2. +2. Every profile badge is bound to the chat it is shown in: a direct chat, a contact request, a link, or a group membership (section 14). A proof bound to another chat is ignored. +3. In p2p groups a badge is accepted from a message signed by the member, from a join request under the request header, and from an introduction under the key in the introduction. In channels it is accepted from a message signed by the member and from the relay's introduction under the key stored for the member: the key from the owner-signed roster for roles from member up, the key in the introduction for lower roles. 4. Files above the default limit include a proof in the invitation and a proof in the description, in every chat type. The core library verifies both. The decision is stored on the file and shown by the apps from one field. 5. Forwarding a file above the forwarder's limit is refused with an alert before the forwarding sheet opens, and again, for the chosen destination, before anything is uploaded. 6. A received file keeps its two proofs, so a file re-sent to a new member as part of history keeps them; the sender's own files get fresh proofs from the credential. -Two new columns on `files`, and a new table `file_badge_proofs` holding the invitation proof and the description proof of a file, kept for history. One new function in simplexmq, the hash of the fields shared by all descriptions of one upload. +Two new columns on `files`, and a new table `file_badge_proofs` holding the invitation proof and the description proof of a file, kept for history. A new table `group_member_badge_proofs` holding the proof a member presented in a group, forwarded in introductions. A new column on `connections`, the request header of a prepared connection. The relay invitation includes the channel's public group id and the owner's member key. In simplexmq: the hash of the fields shared by all descriptions of one upload, the verification codes of a connection, links and invitations before link data is signed, and the minimum SMP version raised to 15. ## Terms @@ -46,7 +46,7 @@ Two new columns on `files`, and a new table `file_badge_proofs` holding the invi **Presentation header.** A byte string that is an input to proof generation and to proof verification. A proof verifies only with the header it was generated with. Type `ProofPresHeader` in `Badges.hs`. `PHTest` is a random nonce. -**Chat binding.** The byte string that identifies the sender in one conversation, produced by `encodeChatBinding` (`Protocol.hs:444`). Message signatures and shared contact cards are computed over it. For a direct chat it is `encodeChatBinding CBDirect adHash`, where `adHash` is the hash of the connection's ratchet data, which both sides obtain with `getConnectionRatchetAdHash`. For a p2p group it is `encodeChatBinding CBGroup (smpEncode (memberId, memberKey))`. For a channel it is `encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))`. `groupBindingData` (`Internal.hs:2261`) computes the inner part for groups. +**Chat binding.** The byte string that identifies the sender in one conversation, produced by `encodeChatBinding` (`Protocol.hs:444`). Message signatures and shared contact cards are computed over it. For a direct chat it is `encodeChatBinding CBDirect codeAD`, where `codeAD` is the hash of the connection's ratchet data, which both sides obtain with `getConnectionVerifyCodes`. For a p2p group it is `encodeChatBinding CBGroup (smpEncode (memberId, memberKey))`. For a channel it is `encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))`. `groupBindingData` (`Internal.hs:2322`) computes the inner part for groups. **Member key.** The Ed25519 key a member holds for one group. It is created when first needed — at group creation on this branch, or by `createUserMemberKey` before the first signed message — and the public key is sent in introductions and in `XInfo`. @@ -64,10 +64,14 @@ data ProofPresHeader | PHChat ByteString | PHFileInv ByteString String Integer | PHFileDescr ByteString String Integer ByteString (Maybe UTCTime) + | PHRequest ByteString + | PHLink ByteString | PHUnknown Char ByteString ``` - `PHChat` holds the chat binding. +- `PHRequest` holds the request code (section 14.1). +- `PHLink` holds the link key. - `PHFileInv` holds the chat binding and the file size from the invitation. - `PHFileDescr` holds the same two values, then the shared description hash (section 8) and the file expiration. @@ -75,57 +79,63 @@ The file name is not part of either header: `validateFileInvitation` replaces it One constructor serves every chat type, because the chat binding already encodes the type of chat in its first byte. Each constructor gets a tag character in `ProofPresHeaderTag` and an encoding in the `StrEncoding` instance, in the same style as `PHTest`. The file expiration is optional, because a server may grant none; it is encoded as `strEncode` of the time, or one fixed byte when absent. The badge's own expiry is a time and is encoded with `strEncode` in the disclosed messages (`badgeInfoMessages`, `Badges.hs:296`). -`verifyBadgeWith` today verifies a proof with whatever header the proof contains. After this change the receiver first checks that the header names the sender as the receiver knows them, and only then runs BBS verification with that header. `proofPresHeaderAccepted` is removed. What the receiver knows is already held by existing types, so no new type is added: +`verifyBadgeWith` today verifies a proof with whatever header the proof contains. After this change the receiver first checks that the header names the sender as the receiver knows them, and only then runs BBS verification with that header. `proofPresHeaderAccepted` is removed; only BBS verification is run by `verifyBadge`, and the header rule is applied by its callers: -- A contact request, link data, and the profile in a direct chat: the header must be `PHTest`. -- A file in a direct chat: the receiver has the contact's connection and obtains its ratchet hash from the agent, as `newContentMessage` does for a contact card (`Subscriber.hs:1883`). The binding in the header must equal `encodeChatBinding CBDirect adHash`. -- A profile or a file in a group: the receiver has the `GroupInfo` and the sender's `GroupMember`. The binding in the header must equal `groupBindingData` for that member — for a channel the group's public id and the member id; for a p2p group the member id and a key that passes the key check. +- A contact request, link data, and the profile in a direct chat: the header of section 14. +- A file in a direct chat: the receiver has the contact's connection and obtains its ratchet hash from the agent, as `newContentMessage` does for a contact card (`Subscriber.hs:1883`). The binding in the header must equal `encodeChatBinding CBDirect codeAD`. +- A file in a group: the receiver has the `GroupInfo` and the sender's `GroupMember`. The binding in the header must equal `groupBindingData` for that member — for a channel the group's public id and the member id; for a p2p group the member id and the member key. +- A profile in a group: for a channel the group's public id, the member id and the member key; for a p2p group the member id and the member key. -**The key check.** A p2p binding contains the sender's member key. The receiver may know that member's key from the introduction or from a signed message, or may not know it yet. If the receiver knows a key and it differs from the key in the header, the proof fails. Otherwise the key in the header is used for this verification and never stored; keys are stored only by the introduction and by `storeMemberKey`. +**The key in a p2p binding.** For a file the receiver may not know the sender's key yet; if it knows one and it differs from the key in the header, the proof fails, otherwise the key in the header is used for this verification and never stored (`proofMemberKey`). For a profile the key is the one the message signature was verified under, section 3; a header key is never used. The file headers are checked the same way and then further: `PHFileInv` must also name the file size as received; `PHFileDescr` must also hold the hash of the received description and the expiration received with it. -`PHUnknown` fails every check. A proof from a released client, which presents `PHTest` in groups, fails in groups; no badge has been issued yet, so nothing in use is affected. A released client that receives one of the new headers verifies it, because its `proofPresHeaderAccepted` admits unknown tags and BBS verification runs with the header bytes as sent. No protocol version change is needed. +`PHUnknown` fails every check. `PHTest`, presented by released clients, is accepted everywhere; its acceptance is dropped in v7.2. A released client that receives one of the new headers verifies it, because its `proofPresHeaderAccepted` admits unknown tags and BBS verification runs with the header bytes as sent. No protocol version change is needed. -`groupBindingData` moves from `Internal.hs` to `Protocol.hs`, beside `encodeChatBinding`, because the store modules import `Protocol` and not `Internal`. Module order fixes where the check is computed: `Badges.hs` is imported by `Types.hs`, which `Protocol.hs` imports, so the header check in `Badges.hs` takes plain values — the expected binding for a channel or a direct chat, or the member id and the stored key for a p2p group — and the callers in the store compute them from `GroupInfo` and `GroupMember` with `groupBindingData`. `profileBadgeVerified` is in `Types.hs` today and cannot call `groupBindingData`; it moves to `Store/Shared.hs`, beside the other badge-verifying store code. +The expected header is computed in the chat layer and passed to the store; `groupBindingData` and `profileBadgeVerified` remain in `Internal.hs` and `Types.hs`. One predicate is defined in `Badges.hs`: `acceptedProof`, true for `PHTest` and for a header equal to the expected one (section 14.3). `SimplexDomainProof` (`Names.hs:37`) also uses `ProofPresHeader`, as an opaque value. Its verification is unchanged. ## 2. Presenting the profile badge -File: `src/Simplex/Chat/Library/Internal.hs`, `presentUserBadge` (`:2178`). +File: `src/Simplex/Chat/Library/Internal.hs`, `presentUserBadge` (`:2238`). -The function generates the proof for an outgoing profile. It takes a new argument, `Maybe GroupInfo`. With `Nothing` it generates `PHTest` as today. With `Just gInfo` it generates `PHChat` from the group's chat binding and the user's own member key in that group, calling `createUserMemberKey` first when the group has no key yet. +The proof for an outgoing profile is generated with `sndBadgeProof`, as file proofs are. The header, `Maybe ProofPresHeader`, is a parameter; with `Nothing`, no badge is presented. For a send into a group the header is `groupPresHeader`, `memberPresHeader` of the user's own member id and key in that group. The membership key is generated and stored by `mkGroupInfoKeys` when the group is read with its keys, and it is set on the membership of the returned group; for a membership without a public key, no badge is presented. The header of every call site is listed in section 14.3. -Call sites that send a profile into a group pass the group: `Commands.hs:3953` (join via group link, the group case), `:4291` (the owner's profile to a relay); `Subscriber.hs:480` (the group case), `:611`, `:799`, `:813`, `:941`, `:1220`, `:3271`; `Internal.hs:2539` (`sendGroupProfileUpdate`). All other call sites send a direct profile and pass `Nothing`. - -The profile in a direct chat keeps `PHTest`; moving it to `PHChat` is a later change. +In the handshake sends (section 4) and in `XGrpAcpt` (section 14.3, item 9), the badge is presented at every peer version; the message is signed only when the peer version is at least `relayWebCapVersion`. A peer below `relayWebCapVersion` does not store the proof; the proof is generated for it anyway, and no version check is added for it. ## 3. Accepting the profile badge -A received badge is verified today at seven places in the store layer, each verifying the proof with no knowledge of the sender: `profileBadgeVerified` (`Types.hs:834`), `createContact_` (`Store/Shared.hs:420`), `createJoiningMember` (`Store/Groups.hs:2089`), `createNewMemberProfile_` (`Store/Groups.hs:2459`), two contact request sites (`Store/ContactRequest.hs:169, 236`), and `linkDataBadge` (`Internal.hs:2194`). +A received badge is verified today at seven places in the store layer, each verifying the proof with no knowledge of the sender: `profileBadgeVerified` (`Types.hs:851`), `createContact_` (`Store/Shared.hs:421`), `createJoiningMember` (`Store/Groups.hs:2085`), `createNewMemberProfile_` (`Store/Groups.hs:2440`), two contact request sites (`Store/ContactRequest.hs:170, 237`), and `linkDataBadge` (`Internal.hs:2253`). -The direct sites keep verifying with `PHTest`. The group sites gain the `GroupInfo` and the sender's `GroupMember` where they do not have them already: `updateMemberProfile` and `updateContactMemberProfile` (`Store/Groups.hs:3430, 3453`) have the member and gain the group, and pass both to `profileBadgeVerified`; `createNewMemberProfile_` gains both from `createNewGroupMember`. A badge from a message that was not verified with the member's key is not verified at all: the caller removes it from the profile before storing, so the store function sees no badge. +The expected header, `Maybe ProofPresHeader`, is a parameter of the store functions and is checked with `acceptedProof` (section 14.3); `Nothing` is passed where a profile is built from a name alone. A proof rejected by `acceptedProof` is ignored: at creation no badge is stored, at update the stored badge is kept. A proof that fails BBS verification is stored as failed. -Where a badge is accepted in a p2p group: +The header is computed in the chat layer by `memberPresHeader gInfo memberId key_` (`Internal.hs`): for a channel `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, key)))`; for a p2p group `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, key)))`; `Nothing` without a key. File proofs keep the binding of message signatures (`rcvGroupChatBinding`). The key passed at each site: -- `xInfoMember` (`Subscriber.hs:2738`): only when the `XInfo` was verified with the member's key. `RcvMessage.msgSigned` is `MSSVerified` when a stored key verified it. When the same message delivers the key, `storeMemberKey` has verified the signature with that key, and the badge is kept on the same basis. Otherwise the badge is removed from the profile before `processMemberProfileUpdate`. -- `xGrpLinkMem` (`:2744`): the host's profile to the joiner, signed on this branch. -- The member connection handshake, section 4. +- `xInfoMember` (`Subscriber.hs:2798`) and `xGrpLinkMem` (`:2804`): the stored key when the message signature is verified with it, otherwise no key. A key delivered by the message is first stored by `storeMemberKey` when the signature is verified with it; the member with the stored key is returned by `storeMemberKey`. +- The member connection handshake, section 4: the stored key when the signature is verified with it. +- Introductions — `createNewGroupMember`, `createIntroReMember`, `updateUnknownMemberAnnounced`, `updateRosterMemberAnnounced`, `updatePreparedChannelMember`: in a channel the key stored for the member after the introduction — the roster key for roles from member up, the key in the `MemberInfo` for lower roles; in a p2p group the key in the `MemberInfo`. +- A join via a relay (`createJoiningMember`, `updateMemberProfile`): the key in `XMember` when the message signature is verified with it. +- The channel owner at a relay (`createRelayRequestGroup`, `relayInvPresHeader`): the owner's key claimed in the invitation, when the invitation is signed with it. +- `XGrpAcpt` at the inviting host: the key delivered by the message, stored before the profile; the binding includes it when the message signature is verified with it. +- A join by `XContact` to a p2p group: the request header (section 14.3). +- A relay's profile, from its link data: `Nothing`. -Where a badge is dropped in a p2p group: `createJoiningMember` and `createNewMemberProfile_`. The profile is stored without the badge, and the member's badge arrives at the handshake. - -In a channel a member's profile arrives in three ways: in `XMember` when the member joins, which the member signs and the owner verifies with the roster key (`verifyKey`, `Subscriber.hs:1656`) before `createJoiningMember`; in the introduction from a relay, stored by `createNewMemberProfile_`; and in `XInfo`. A badge in any of them is kept and verified, because member keys in a channel are established by the roster, which the owner signs, and `xGrpMemNew` rejects a relay that asserts a different key (`Subscriber.hs:3127-3134`). +In a channel a badge is accepted from `XMember` and `XInfo` whose signature was verified under the member's key, and from the introduction by a relay under the key stored for the member (section 14.3, item 9). In a p2p group a badge is accepted from a message whose signature was verified under the member's key, from a join request under the request header, and from an introduction under the key in the introduction. ## 4. The member connection handshake -When two p2p members connect, each sends `XGrpMemInfo` with its group profile. It is sent from two places: the reply on the member connection (`Subscriber.hs:816`) and the join of the member connection and of the direct connection to the same member (`:3272`, both joined with the same message at `:3282-3283`). The four receiving sites — `:590, 620` on the direct connection, `:810, 823` on the member connection — each have a "TODO update member profile" comment. +When two p2p members connect, each sends `XGrpMemInfo` with its group profile. It is sent from two places: the reply on the member connection (`Subscriber.hs:843`) and the join of the member connection and of the direct connection to the same member (`:3336`, both joined with the same message at `:3345-3347`). The four receiving sites — `:615, 647` on the direct connection, `:837, 850` on the member connection — each have a "TODO update member profile" comment. -- **Sign the join side.** `xGrpMemFwd` sends `encodeConnInfo $ XGrpMemInfo ...` (`:3272`), plain JSON. Change it to `encodeSignedConnInfo` with `groupMsgSigning` when the agreed version is at least `relayWebCapVersion`. The agreed version is computed three lines below, as `chatV`; move that computation above the send. Call `createUserMemberKey` before signing, here and at the reply site, as every other signing site does. -- **Parse the signature on CONF.** The member CONF site parses with `parseChatMessage` (`:745`), which discards the signature. Change it to `parseChatMessage'`, as INFO already does (`:823`). -- **Verify the signature.** At `:810` and `:823` verify the signed `XGrpMemInfo` with the member's stored key. `XGrpMemInfo` names no key, and the handshake follows the introduction, which stored the key. A member with no stored key is not verified. -- **Store the profile.** At `:810` and `:823` call `processMemberProfileUpdate` with the profile, with the badge removed when the signature did not verify. -- `:590` and `:620` stay as they are. The profile there is the same group profile, received over the direct connection to the member. The contact for a member shares the member's profile row (`createIntroToMemberContact`, `Store/Groups.hs:2684-2685`), so storing it once, on the member connection, updates both. +The badge is presented in `XGrpMemInfo` at every peer version, by `membershipHandshakeProfile`. `XGrpMemInfo` is signed by `groupMsgSigning` when its profile includes a badge and the peer version is at least `relayWebCapVersion`. A `PHChat` proof is kept only from a verified signature. + +- **Sign the join side.** In `xGrpMemFwd` (`:3336`), `XGrpMemInfo` is encoded with `encodeSignedConnInfo` when a signing is returned by `groupMsgSigning` and the agreed version, `chatV`, is at least `relayWebCapVersion`; `GroupInfoKeys` is passed from the dispatch. +- **The reply side** (`:843`): the message is signed by `allowAgentConnectionAsync` when the maximum of the connection's `peerChatVRange` is at least `relayWebCapVersion`. +- **Parse the signature on CONF.** The member CONF site (`:781`) is parsed with `parseChatMessage'`, as INFO is (`:847`). +- **Verify and store.** At `:837` and `:850` the signed message is passed to `processMemberProfileUpdate`, and the signature is verified by `signedMemberPresHeader` with the stored member key: the key from the introduction, or, at `:850` for the inviting host, the key from `XGrpInv`. The profile is stored with the binding of the verified key, or with no binding. +- `:615` and `:647` are unchanged: the profile there is the same group profile, received over the direct connection to the member, and the member's profile row is used by the contact for the member (`createIntroToMemberContact`). + +For a member whose key was never introduced, a `PHChat` proof is accepted only from a message that delivers the key and is signed with it. ## 5. The file size limit at send @@ -254,11 +264,374 @@ The six proof columns are the fields of `BadgeProof` — the proof, the presenta ## 13. Tests - `BadgeTests.hs`: each header encodes and decodes; a proof generated with one header fails with another; the key check accepts an unknown key, accepts an equal key, rejects a different one. -- `ChatTests/Profiles.hs`, beside the seven badge tests: a badge in a p2p group appears at the other member after the connection handshake and not before; a proof presented under another member's binding is rejected; a badge in a channel appears on presentation. Existing tests that assert a badge at introduction time are updated. +- `ChatTests/Profiles.hs`, beside the seven badge tests: a badge in a p2p group is shown at the other member from the introduction and from the connection handshake; a proof presented under another member's binding is ignored; a badge in a channel is shown on presentation. The tests of section 14.3 follow. - `ChatTests/Files.hs`, beside `testXFTPGroupFileTransfer`: a file above the default limit from a badge holder is received in a group and in a direct chat; an invitation whose proof was made for another member is refused; a description with a changed hash fails before download; a file above the limit received as history is received by the new member; a forward into an incognito membership above the default limit fails the command before any upload. - `ProtocolTests.hs`: the new fields in `FileInvitation` and `XMsgFileDescr`. +## 14. Bindings in every chat + +Every profile badge is bound to the chat it is shown in. The sender presents the proof under the header of that chat; the receiver computes the expected header and accepts the proof under it, and under `PHTest`. Implementation starts in simplexmq, in `/code/simplexmq-4`; the chat then builds against it. Line references in this section are to the branch; in sections 1-13, to master. + +Rules: + +1. A ratchet held by both sides: the ratchet code. +2. A connection request before the ratchet: the request code — the proposing party's X448 keys, its PQ key when present, and the id of the queue the joining party sends to. +3. A link shown to anyone who holds it: the link key. +4. A group: the member identity. + +| Context | Received by | Header | +|---|---|---| +| Direct chat: `XInfo`, `CONF` and `INFO` replies, accept, one-time link join, member contacts | contact | `PHChat (encodeChatBinding CBDirect (sha256 ("SimpleX badge" <> codeAD)))` | +| Request to an address with ratchet keys | address owner | `PHChat (encodeChatBinding CBDirect (sha256 ("SimpleX badge" <> codeAD)))` | +| Request to an address without ratchet keys | address owner, group host | `PHRequest code` — the joining party's keys, the address queue | +| One-time invitation link data | joining party | `PHLink linkKey` | +| Address link data | anyone with the link | `PHLink linkKey` | +| P2p group | members | `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, memberKey)))` | +| Channel | subscribers | `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, memberKey)))` | + +An address is a contact address, a business address, or a group link. The link key is part of the link: `sha3_256` of the fixed link data — the agent version range, the root key, the connection request with its server and queue id, and the entity id of an address (`encodeSignFixedData`, `ShortLink.hs`). + +### 14.1 Agent: verification codes + +Merged in simplexmq `5294b7d8`, except the ratchet stored by `newConnToAcceptDR`, its use in `startJoinInvitationDR`, and their tests. + +**Second verification code.** `RatchetInitParams` gains `rcVerifyCodePQ` and `Ratchet` gains `rcVCPQ :: Maybe Str`. The code is derived in `pqX3dh` with a separate HKDF over the same inputs, info `SimpleXVerifyCode`, 32 bytes; the ratchet keys and `rcAD` are unchanged. The code is exported keying material over every handshake input, the KEM included — the fourth of the paper's mitigations. A ratchet created before this change is decoded with `rcVCPQ = Nothing`, and its code is set at the next ratchet resync. The chat uses the AD code, `codeAD`, for the security code and for badge bindings; `codePQ` is stored. + +**Columns.** Migration `M20260919_ratchet_verify_codes`, SQLite and Postgres, both schema dumps: + +```sql +ALTER TABLE ratchets ADD COLUMN rc_verify_code_ad BLOB; +ALTER TABLE ratchets ADD COLUMN rc_verify_code_pq BLOB; +``` + +`BYTEA` on Postgres. Both are written by `createRatchet` and `createSndRatchet`, also through their `ON CONFLICT` update, by which a ratchet is recreated at a resync. + +One store function, `getRatchetVerifyCodes :: DB.Connection -> [ConnId] -> IO (Map ConnId ConnVerifyCodes)`, is used for one id or many: + +```sql +SELECT conn_id, rc_verify_code_ad, rc_verify_code_pq, CASE WHEN rc_verify_code_ad IS NULL THEN ratchet_state END +FROM ratchets +``` + +- A row with `rc_verify_code_ad` is used as is. +- A row with the ratchet state is decoded, and its codes are computed and saved. +- A row with x3dh keys only is skipped. +- SQLite: one SELECT per id; the computed codes are saved with one `executeMany` UPDATE. +- Postgres: one SELECT with `In connIds`; the computed codes are saved with one `UPDATE ... FROM (VALUES ...) RETURNING`; codes saved by a concurrent read are kept. + +It is used by `getConnectionVerifyCodes` with one id, with `CONN NOT_FOUND` for a connection without codes, and by `getConnectionsVerifyCodes` with many. The result is `ConnVerifyCodes {codeAD, codePQ}`, defined in `Agent/Protocol.hs`, where `codeAD = sha256 rcAD` and `codePQ` is the PQ code as derived. + +**Prepare step.** The result of `prepareConnectionToJoin` is `(ConnId, ContactRequestBinding)`, the binding for the message the chat composes next: + +```haskell +data ContactRequestBinding = CRBRatchet ConnVerifyCodes | CRBRequest ByteString +``` + +- `CRInvitationUri`: the sender ratchet is created (`createRatchet_`, local — the link's keys and a fresh keypair); the binding is `CRBRatchet` of its codes. +- `CRContactUri` with ratchet keys: the same, from the address keys. +- `CRContactUri` without keys: the x3dh keys are generated and stored (`generateRcvE2EParams`, `createRatchetX3dhKeys`); the binding is `CRBRequest (sha256 (smpEncode (k1, k2, kem, senderId)))` — the request's public keys and the queue id from the link's `SMPQueueUri`. + +The same pair is returned by `prepareConnectionToAccept`: for `CRInvitation` the ratchet is created from the invitation's keys, for `CRInvitationDR` the ratchet in the invitation is stored with the connection by `newConnToAcceptDR`. In `startJoinInvitation` the stored ratchet is used, and a ratchet is created only for a connection without one, as in the contact path and its retry branch. In `startJoinInvitationDR` the ratchet in the invitation is written by `createRatchet` after `updateNewConnSnd`, in one transaction: it is written only while the connection has no send queue, and a ratchet used in a send is never replaced. In `createConnReq` the x3dh keys are read before they are generated, as in `mkJoinInvitation`. The stored ratchet is used by async joins and accepts. The prepare step is local. + +**Events.** A `ContactRequestBinding` field in `REQ`: `CRBRequest` in `smpInvitation`, computed from the received `CRInvitationUri` and the queue of the request; `CRBRatchet` in `smpContactRequest`, from the ratchet initialised there. `CONF` and `INFO` are unchanged: the receiver's ratchet is stored before the notification, so its codes are available to `getConnectionVerifyCodes`. + +**Tests.** `DoubleRatchetTests`: the parties agree on `rcVerifyCodePQ`, a substituted KEM key makes it differ while `assocData` matches, and a ratchet stored before the change decodes with `rcVCPQ = Nothing`. `FunctionalAPITests`: both peers get the same codes, and codes cleared from a row are recomputed and saved on the next read. For an address with ratchet keys, the codes of the requester's prepare step, of `REQ`, of the acceptor's prepare step and of the stored ratchet are equal before `acceptContact`; an accept of a prepared connection without a ratchet is completed by `acceptContact`. + +### 14.2 Agent: links before link data + +Files: `Agent.hs`, `Agent/Client.hs`, `Agent/Protocol.hs`, `Crypto/ShortLink.hs`, `Protocol.hs`, `Transport.hs`, `tests/AgentTests/FunctionalAPITests.hs`. + +The chat pins the simplexmq commit in `cabal.project` and `scripts/nix/sha256map.nix`. + +A link is available to the chat before its link data is signed: a contact link with its short link from `prepareConnectionLink`, an invitation as its full link with its link key from `prepareInvitationLink`. One network request is made per creation. + +**New links.** The short link of an invitation, with the link id assigned by the server, is returned by `createInvitationForLink`: + +```haskell +prepareConnectionLink :: AgentClient -> UserId -> C.KeyPairEd25519 -> ByteString -> Bool -> Maybe CRClientData -> CR.InitialKeys -> UseRatchetKeys -> Maybe SMPServerWithAuth -> AE (CreatedConnLink 'CMContact, PreparedLinkParams (Maybe (RatchetKeyId, CR.RcvE2EPrivRatchetParams 'C.X448))) + +createConnectionForLink :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> CreatedConnLink 'CMContact -> PreparedLinkParams (Maybe (RatchetKeyId, CR.RcvE2EPrivRatchetParams 'C.X448)) -> UserConnLinkData 'CMContact -> SubscriptionMode -> AE ConnId + +prepareInvitationLink :: AgentClient -> UserId -> CR.InitialKeys -> Maybe SMPServerWithAuth -> AE (ConnectionRequestUri 'CMInvitation, PreparedLinkParams (CR.RcvE2EPrivRatchetParams 'C.X448)) + +createInvitationForLink :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> ConnectionRequestUri 'CMInvitation -> PreparedLinkParams (CR.RcvE2EPrivRatchetParams 'C.X448) -> UserConnLinkData 'CMInvitation -> SubscriptionMode -> AE (ConnId, CreatedConnLink 'CMInvitation) +``` + +- The contact functions keep the signatures of master, with the type parameter of `PreparedLinkParams`. +- `PreparedLinkParams` is parameterised by the keys stored at creation; `plpAddressKeys` is renamed `plpRatchetKeys`: + + ```haskell + data PreparedLinkParams k = PreparedLinkParams + { plpNonce :: C.CbNonce, + plpQueueE2EKeys :: C.KeyPairX25519, + plpLinkKey :: LinkKey, + plpRootPrivKey :: C.PrivateKeyEd25519, + plpSignedFixedData :: ByteString, + plpSrvWithAuth :: SMPServerWithAuth, + plpInitKeys :: InitialKeys, + plpRatchetKeys :: k + } + ``` + + - contact link: `k = Maybe (RatchetKeyId, RcvE2EPrivRatchetParams 'C.X448)`, the address ratchet keys + - invitation: `k = RcvE2EPrivRatchetParams 'C.X448`, the X3DH keys +- Prepare, `prepareLinkParams :: ConnectionModeI c => C.KeyPairEd25519 -> Maybe ByteString -> Maybe CRClientData -> CR.InitialKeys -> SMPServerWithAuth -> QueueMode -> (ConnReqUriData -> ConnectionRequestUri c) -> k -> AM (ConnectionRequestUri c, PreparedLinkParams k)`: + - the nonce and the queue keys are generated; + - the connection request is built by the given function from the queue of the given mode; + - its fixed data is signed with the root key and the entity id. +- `prepareConnectionLink'`: the server is chosen; client notices are checked when `checkNotices` is set; the address ratchet keys are generated when `useDR` is set; `QMContact`. +- `prepareInvitationLink'`: + - the server is chosen and the root key pair is generated; + - X3DH keys from `CR.generateRcvE2EParams`, PQ support from `CR.initialPQEncryption True pqInitKeys`; + - `CRInvitationUri` with the X3DH parameters, `QMMessaging`; + - the entity id and the client data are `Nothing`. +- Create, `createLinkQueue :: AgentClient -> NetworkRequestMode -> UserId -> Bool -> SConnectionMode c -> PreparedLinkParams k -> SubscriptionMode -> ClntQueueReqData -> (ConnId -> AM ()) -> AM (ConnId, RcvQueue, SMPQueueUri)`: + - the connection is created; + - the keys are stored by the given action; + - the queue is created by `createRcvQueue`; + - the connection is deleted when either step fails. +- `createConnectionForLink'`: + - the link data is encrypted by `encryptContactLinkData` before the connection is created; + - the address ratchet keys are stored by `storeAddressRatchetKeys`; + - the sender id is compared after `NEW`, as on master. +- `createInvitationForLink'`: + - link data: `SL.encodeSignUserData SCMInvitation`, encrypted with `SL.invShortLinkKdf plpLinkKey`; + - queue request: `CQRMessaging (Just CQRData {linkKey, privSigKey, srvReq = (sndId, srvData)})`; + - the X3DH keys are stored by `createRatchetX3dhKeys`; + - the returned link from `connReqWithShortLink`: `CSLInvitation` with the link id from the server, PQ keys removed from the full link for `IKPQOn`. +- Tests: + - a connection via an invitation made by prepare and create: its link data is read by the joining party and holds the prepared invitation; `plpLinkKey` equals the key in the returned `CSLInvitation`; for `IKPQOn` the returned full link differs from the prepared invitation + - for link data above the size limit, `CMD LARGE` is returned for a contact link and for an invitation, and no connection is stored + +**Existing connections.** The link of a connection is returned without a network call: + +```haskell +prepareConnShortLink :: AgentClient -> ConnId -> Maybe CRClientData -> AE (ConnShortLink 'CMContact) +``` + +- A stored link is returned as is. +- Otherwise the credentials are created and stored by `newContactLinkCreds :: AgentClient -> RcvQueue -> Maybe CRClientData -> AM (ShortLinkCreds, C.SbKey)`: the signing key pair is generated, the fixed data is built from the connection request without ratchet keys, signed and encrypted (`SL.encryptFixedData`), and `ShortLinkCreds` are stored; the link data key is returned with them. +- In `setConnShortLink`, `newContactLinkCreds` is used for a connection without stored credentials, and the user data is then encrypted with the returned key and uploaded with one `LSET`. For stored credentials, the key is derived once, by the link id check. +- Tests: for an address without a short link, the link is the same from two `prepareConnShortLink` calls and from `setConnShortLink`; a requester connects via it. + +**SMP versions below 15.** The minimum SMP version of clients and servers is 15, and the code for older versions is removed: + +- `NEW` and `IDS` (`Protocol.hs`): the encodings and parsers for versions below 15, with `qReq` and `qm`. +- `mkShortLinkCreds` (`Agent/Client.hs`): link data without a link id in the response is an error; the `THandleParams` parameter is removed. +- `connReqWithShortLink`: absent short link credentials are an `INTERNAL` error. +- `Transport.hs`: `shortLinksSMPVersion` is renamed `_shortLinksSMPVersion` and is not exported; `_proxyServerHandshakeSMPVersion` is removed. +- Tests: `testInvitationShortLinkPrev` and `testProxyMatrixWithPrev` are removed. + +### 14.3 Chat + +In implementation order. A badge is presented only with a header: no badge is presented at a direct send for a connection without codes or for a retry without a stored request header, and at a send into a group for a `Nothing` from `groupPresHeader`. The agent is asked for codes or a link only when a badge is presented, or when a received proof has a header other than `PHTest`. An agent error is returned as a chat error. A failed read of verification codes fails the whole operation — a profile update, a badge broadcast, or a reply to a confirmation; the profile is not sent to the contacts whose codes were read, and no partial delivery is reported. + +**1. Headers** — `Badges.hs` + +- `PHRequest ByteString` in `ProofPresHeader`, tag `'R'`. +- `PHLink ByteString` in `ProofPresHeader`, tag `'L'`; payload: the link key bytes. +- `acceptedProof :: Maybe ProofPresHeader -> BadgeProof -> Bool`: true for `PHTest`, and for a header equal to `strEncode` of the expected one. `proofPresHeaderAccepted` and `verifyBadge_` are removed. +- `acceptedBadge :: Maybe ProofPresHeader -> Maybe BadgeProof -> Maybe BadgeProof -> Maybe BadgeProof`: the received proof when it is accepted or absent, otherwise the stored one. Used by `profileBadgeVerified`, `processContactProfileUpdate`, `processMemberProfileUpdate` and `setMemberBadgeProof`. +- `sameBadgeProof :: Maybe BadgeProof -> Maybe BadgeProof -> Bool`: equal disclosed information and equal headers, where the random header of `PHTest` is not compared. Used by `processMemberProfileUpdate` and `setMemberBadgeProof`. +- `unboundProof :: BadgeProof -> Bool`: true for `PHTest`. +- `ToField` and `FromField` for `ProofPresHeader`: the `strEncode` bytes as a blob. + +**2. Ignored proofs** — `Types.hs`, store modules + +- A proof rejected by `acceptedProof` is ignored. +- At creation no badge is stored: `createContact_`, `createContactRequest` (`ContactRequest.hs:170`), `createJoiningMember`, `createNewMemberProfile_`. +- At update the stored badge is kept: the badge to store and its verification are returned by `profileBadgeVerified` — for a rejected proof, the stored proof, verified again when its stored status is `BSFailed` or `BSUnknownKey`. Used in `updateContactProfile`, `updateMemberProfile`, `updateContactMemberProfile`, and the request update (`ContactRequest.hs:239`). +- `proofPresHeaderAccepted` is removed from `verifyBadgeWith`, and `verifyBadge_` is removed; at the creation sites `profileBadgeVerified` is called with no stored profile. `BSFailed` is stored only for a failed BBS verification. + +**3. Expected header in the store** + +The expected header, `Maybe ProofPresHeader`, is a parameter of: + +- `updateContactProfile` +- `createDirectContact` +- `createPreparedContact` +- `createOrUpdateContactRequest` +- `updateMemberProfile` +- `updateContactMemberProfile` +- `createJoiningMember` +- `createNewGroupMember` +- `createIntroReMember` +- `updateUnknownMemberAnnounced` +- `updateRosterMemberAnnounced` +- `updatePreparedChannelMember` +- `createRelayRequestGroup` + +`Nothing` is passed by `createContact` for the preset contact card, by `setRelayLinkAccepted` and `updateRelayMemberData` for a relay profile, by `updatePreparedUserAndHostMembers'` for a host profile built from a name, and by `acceptGroupJoinSendRejectAsync`. In `processMemberProfileUpdate` the header is computed from the signed message by `signedMemberPresHeader`. Other group headers are computed by `memberPresHeader` or `memberInfoPresHeader`; the request header is passed for a join by `XContact`, and `relayInvPresHeader` for a relay invitation. + +**4. Presenting** — `Internal.hs` + +```haskell +presentUserBadge :: User -> Maybe i -> Maybe ProofPresHeader -> Profile -> CM Profile + +presentUserBadgeWith :: User -> Maybe i -> CM (Maybe ProofPresHeader) -> Profile -> CM Profile +``` + +With `Nothing`, no badge is presented. A badge is presented only when `presentsUserBadge :: User -> Bool` holds, for a profile that is not incognito: the user's own badge is active or expired. `presentUserBadgeWith` runs the header action only in that case; `presentUserBadge` is `presentUserBadgeWith` of a known header. Header helpers: + +- `groupPresHeader :: GroupInfo -> Maybe ProofPresHeader` — `memberPresHeader` of the membership's id and key +- `directPresHeader :: ContactRequestBinding -> ProofPresHeader` — `PHChat (encodeChatBinding CBDirect (sha256 ("SimpleX badge" <> codeAD)))` for `CRBRatchet`, `PHRequest code` for `CRBRequest`. The profile header holds the labelled hash, so a profile proof does not include the input of the security code; file proofs keep `encodeChatBinding CBDirect codeAD`. +- `linkPresHeader :: ConnShortLink c -> ProofPresHeader` — `PHLink` of the link key +- `memberKeyPresHeader :: Maybe B64UrlByteString -> MemberId -> C.PublicKeyEd25519 -> ProofPresHeader` — `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, key)))` with a channel id, `PHChat (encodeChatBinding CBGroup (smpEncode (memberId, key)))` without one +- `memberPresHeader :: GroupInfo -> MemberId -> Maybe C.PublicKeyEd25519 -> Maybe ProofPresHeader` — `memberKeyPresHeader` of the channel id in a channel and of no id in a p2p group; `Nothing` without a key +- `memberInfoPresHeader :: GroupInfo -> MemberInfo -> Maybe ProofPresHeader` — `memberPresHeader` of the id and key in the `MemberInfo` +- `relayInvPresHeader :: Maybe SignedMsg -> GroupRelayInvitation -> Maybe ProofPresHeader` — `memberKeyPresHeader` of the channel id, the owner's id and the key in the invitation, for an invitation signed with that key (item 12) +- `connPresHeader :: Connection -> CM (Maybe ProofPresHeader)` — `connsPresHeaders` of one connection +- `connsPresHeaders :: [Connection] -> CM (Map ConnId ProofPresHeader)` — `directPresHeader . CRBRatchet` of `getConnectionsVerifyCodes`; the map includes the connections with codes + +**5. The binding from prepare steps** + +The result of each includes `directPresHeader` of the agent binding: + +- `prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat, ProofPresHeader)` +- `prepareAgentJoin :: User -> Bool -> ConnectionRequestUri c -> CM ((CommandId, ConnId), ProofPresHeader)` +- `prepareAgentAccept :: User -> Bool -> InvitationId -> PQSupport -> CM ((CommandId, ConnId), ProofPresHeader)` + +**6. Stored request header** + +Migration `M20260925_badge_bindings`, SQLite and Postgres, first statement; the rest is in item 11: + +```sql +ALTER TABLE connections ADD COLUMN pres_header BLOB; +``` + +`BYTEA` on Postgres. Both schema dumps and `chat_query_plans.txt` are updated. + +- `createConnReqConnection`: the request header is a parameter, written to `pres_header`. +- `getConnReqContact`: the result is `(ConnReqContact, Maybe ProofPresHeader)`, read from `via_contact_uri` and `pres_header`. + +**7. Direct sends** + +- `joinContact` (`Commands.hs:3982`): the request header, `Maybe ProofPresHeader`, is a parameter, set in `connect'`, `joinPreparedConn'` and `connectContactViaAddress` — the `prepareContact` header for a new connection, the stored header for a retry. The badge is presented with `groupPresHeader` in a relay group, and with the request header otherwise. +- `connectViaInvitation` (`Commands.hs:3808-3845`) and `connectMemberContact` (`:3395-3422`): the prepare binding for a new connection; `connPresHeader` for a prepared one, by `presentUserBadgeWith`. +- `joinMemberContactAsync` (`Subscriber.hs:3952`): the header is a parameter, set in `xGrpDirectInv` to the `prepareAgentJoin` header. +- `acceptContactRequest` (`Internal.hs:984-1014`): the prepare binding for a new connection; `connPresHeader` for an existing one, by `presentUserBadgeWith`. +- `acceptContactRequestAsync` (`Internal.hs:1016-1035`): the profile is built after `prepareAgentAccept`, from its header. +- `CONF` replies (`Subscriber.hs:506` direct case, `:623`) and `updateContactPrefs` (`Commands.hs:4099`): `connPresHeader`, by `presentUserBadgeWith`. +- `sendUpdateToContacts` (`Commands.hs:4043-4082`) and `presentUserBadgeToContacts` (`:5215-5230`): one `connsPresHeaders` call per command, when `presentsUserBadge` holds. + +**8. Direct receipts** + +- `REQ` (`Subscriber.hs:1399`): `directPresHeader` of the `REQ` binding is a parameter of `profileContactRequest`, passed to `createOrUpdateContactRequest` and to `acceptGroupJoinRequestAsync`. +- `processContactProfileUpdate` (`Subscriber.hs:2761`) and `saveConnInfo` (`:3185`, for `createDirectContact`): `connPresHeader` of the connection the profile was received on, read only for a received proof with a header other than `PHTest`. +- In `processContactProfileUpdate` the profile with the stored proof in place of a rejected one is used for the comparison and the chat item, as for members (item 11). + +**9. Groups** + +- `groupPresHeader` at every send into a group: `Commands.hs:4002` (`joinContact`, relay group), `:4331`; `Subscriber.hs:505` group case, `:636`, `:829`, `:971`, `:1253`, and `membershipHandshakeProfile :: User -> GroupInfo -> CM Profile` (`Internal.hs`) for `:791`, `:843`, `:3371` and `addRelays`; `Internal.hs:2546` (`encodeXGrpAcpt`) and `:2730`. +- `acceptGroupJoinRequestAsync` (`Internal.hs:1037`): the expected header is a new parameter, passed to `createJoiningMember` and `updateMemberProfile` — in `memberJoinRequestViaRelay`, `memberPresHeader` of the joining member's id and the key in `XMember` when the message signature is verified with it. The signature is verified once, for the roster key check and for the header. `Nothing` is passed to `createJoiningMember` in `acceptGroupJoinSendRejectAsync`. +- Host `INFO` with `XInfo` (`Subscriber.hs:858-865`): after `storeMemberKey`, the profile is stored by `processMemberProfileUpdate` with `signedMemberPresHeader`, under the member's key. +- The profile is also stored by `processMemberProfileUpdate` when the received proof is accepted and differs from the stored member proof in its header or its disclosed information, and when a profile without a proof is received for a member with a stored proof; proofs are compared by `sameBadgeProof`. +- The member event is sent when the profile content or the badge status of the member changes; a change of the proof alone sends no event. +- Introductions: + - In `memberInfo` (`Internal.hs:1344`) the stored proof (item 11) is included when `acceptedProof` holds under `memberPresHeader` of the member's id and stored key, in channels and in p2p groups. + - In `xGrpMemNew`, `xGrpMemIntro` and `xGrpMemFwd` (`Subscriber.hs:3213, 3296, 3347`) the header is built from the key stored for the member after the introduction: + - a roster member in `xGrpMemNew` and an existing member in `xGrpMemIntro`, in a channel: `memberPresHeader` of the stored key; + - a new member in `xGrpMemIntro` in a channel: `memberInfoPresHeader` of the introduction with the key removed for roles from member up; + - other introductions, in channels and in p2p groups: `memberInfoPresHeader` of the introduction, whose key is stored. +- Invitation via contact: + - `profile :: Maybe Profile` in `XGrpAcpt`, the optional JSON field `profile`. + - The invitee (`Commands.hs:2851`, `Subscriber.hs:2700`, both by `encodeXGrpAcpt`, `Internal.hs:2541-2549`) includes its group profile, with `groupPresHeader`; the message is encoded by `encodeConnInfoSigning`, signed when a signing is returned by `groupMsgSigning` and the maximum of the contact connection's `peerChatVRange` is at least `relayWebCapVersion`. + - `XGrpAcpt` with a badge in its profile is signed by `groupMsgSigning` (`Internal.hs:2360-2363`). + - `encodeConnInfoSigning :: PQSupport -> Maybe MsgSigning -> ChatMsgEvent e -> CM ByteString` — `encodeSignedConnInfo` with a signing, `encodeConnInfoPQ` without one; used by `encodeXGrpAcpt`, `xGrpMemFwd` and `allowAgentConnectionAsync`. + - The host (`Subscriber.hs:785-795`) stores the key, allows the connection, then stores the profile by `processMemberProfileUpdate` with the signed message. For an invitee whose contact is active, the profile row is kept by `contactUpdatableFromMember` and the proof is stored on the membership (item 11). + - The host replies with `XGrpMemInfo` and its group profile in place of `XOk` (`Subscriber.hs:791-793`); the badge is presented at every version, and the reply is signed by `allowAgentConnectionAsync` from `relayWebCapVersion`, as at `:843-845`. + +**10. Link data** + +- Invitation links (`APIAddContact`, `recreateConn`): the chat calls `prepareInvitationLink`; the badge is presented with `PHLink` of `plpLinkKey`; then `createInvitationForLink` is called with the invitation and the params. +- Invitation updates (`updatePCCShortLinkData`, `APISetConnectionIncognito`): `linkPresHeader` of the stored invitation short link. +- Address (`APICreateMyAddress`): `linkPresHeader` of the key in the prepared `CSLContact`. +- Address updates (`setMyAddressData`): the key of the stored short link. For an address without a short link, the key is taken from `prepareConnShortLink` when a badge is presented, then `setConnShortLink` is called once. +- Receipts: + - `linkDataBadge` (`Internal.hs:2285`): the expected header is a parameter — `linkPresHeader` of the short link, for an invitation (`Commands.hs:4415`) and for an address (`:4469-4470`). For a rejected proof the display badge, `localBadge`, is cleared. + - `createPreparedContact` (`Commands.hs:2206`): `linkPresHeader` of the short link in `accLink`; `Nothing` without a short link. + - `updateContactFromLinkData` (`Internal.hs:1644`): the header of the address link is a parameter. + +**11. Member proofs** — `Badges.hs`, `Types.hs`, `Types/Preferences.hs`, `Store/Shared.hs`, `Store/Groups.hs`, `Store/Connections.hs`, `Internal.hs`, `Subscriber.hs` + +A member's accepted proof is stored on the membership, in `group_member_badge_proofs`, and forwarded in introductions. The badge in the profile row is kept for display: the profile row of a member linked to a contact is the contact's. + +Migration `M20260925_badge_bindings`, after the statement of item 6, SQLite: + +```sql +CREATE TABLE group_member_badge_proofs( + group_member_id INTEGER PRIMARY KEY REFERENCES group_members ON DELETE CASCADE, + badge_proof BLOB NOT NULL, + badge_pres_header BLOB NOT NULL, + badge_key_idx INTEGER NOT NULL, + badge_type TEXT NOT NULL, + badge_expiry TEXT NOT NULL, + badge_extra TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +``` + +Postgres: `BIGINT`, `BYTEA` and `TIMESTAMPTZ`. Both schema dumps and `chat_query_plans.txt` are updated. + +- One row per member, keyed by the member id, as `rcv_files` is keyed by the file id. +- `MaybeBadgeProofRow` and `maybeRowToBadgeProof` in `Badges.hs`: the six proof columns of a `LEFT JOIN`. +- `PrefsJSON` is generalised: + + ```haskell + newtype NoJSON a = NoJSON {unNoJSON :: Maybe a} + + type PrefsJSON = NoJSON Object + ``` + + The field is omitted by `ToJSON`; `NoJSON Nothing` is returned by `FromJSON`. `NoJSON` is used at the construction sites. +- `GroupMember` gains `memberBadgeProof :: NoJSON BadgeProof`. It is removed from the bot API docs: `removeField "memberBadgeProof" $ sti @GroupMember`. +- Reads: + - The proof columns are selected by `groupMemberQuery` after the connection columns, with `LEFT JOIN group_member_badge_proofs bp ON bp.group_member_id = m.group_member_id`; the field is set by `toContactMember`. + - They are selected the same way by the member read of `getConnectionEntity` (`Connections.hs:145-194`); the field is set by `toGroupAndMember`. + - `NoJSON Nothing` is set by `toGroupMember`, for the membership, chat item members and quoted members. +- Writes, for a received profile: + - a badge accepted by `acceptedProof` under the expected header, different from the stored proof, and verified by `verifyBadge` is upserted by `createMemberBadgeProof` (`INSERT ... ON CONFLICT (group_member_id) DO UPDATE`); a badge that does not verify keeps the row; + - for a profile without a badge and a member with a stored proof, the row is deleted; + - for a badge not accepted, or equal to the stored proof by `sameBadgeProof`, the row is kept. +- These rules are applied by `setMemberBadgeProof :: DB.Connection -> StoreCxt -> GroupMember -> Maybe ProofPresHeader -> Profile -> IO GroupMember`, and the member with the field set is returned. Used by `updateMemberProfile` and `updateContactMemberProfile`, and by `processMemberProfileUpdate` when `contactUpdatableFromMember` is false. +- The received profile is passed to `updateMemberProfile` after `redactedMemberProfile`, and unredacted to `updateContactMemberProfile` and `setMemberBadgeProof`. The profile with the stored proof in place of a rejected one is used for the comparisons, the business chat profile and the chat item. +- At creation the accepted proof is inserted when its verification succeeded (`verifiedProof`): `createNewMember_` (for `createNewGroupMember` and `createIntroReMember`), `createJoiningMember`, and the owner in `createRelayRequestGroup`. +- `memberBadgeProof`, or the profile's proof when it is absent, is included by `memberInfo`, filtered by `acceptedProof` under `memberPresHeader g memberId memberPubKey`. + +**12. The channel owner at the relay** — `Types.hs`, `Commands.hs`, `Subscriber.hs`, `Internal.hs`, `Store/Groups.hs`, `Store/RelayRequests.hs` + +- `GroupRelayInvitation` gains `publicGroupId :: Maybe B64UrlByteString` and `fromMemberKey :: Maybe MemberKey`, the optional JSON fields `publicGroupId` and `fromMemberKey`. The owner sets them in `addRelays` from `publicGroup' gInfo` and from `groupMemberKey` of the group keys; `addRelays` takes `GroupInfoKeys`. +- The invitation is always signed: `XGrpRelayInv_` is in `requiresSignature`, and the invitation is encoded by `encodeConnInfoSigning` with `groupMsgSigning`. Released relays parse the signed message and ignore the signature. +- `relayInvPresHeader :: Maybe SignedMsg -> GroupRelayInvitation -> Maybe ProofPresHeader` (`Subscriber.hs`) — `PHChat (encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId, memberKey)))` with the owner's member id and key, when the signature is verified with that key under `smpEncode (publicGroupId, memberId)`; `Nothing` without both claims or without the signature. The signature is verified by `verifyMemberSig`, which `verifyGroupSig` uses with `groupBindingData`. +- It is computed in `xGrpRelayInv` and passed to `createRelayRequestGroup` as the expected header, there and through `rejectRelayInvitationAsync`. The owner's profile and membership proof are stored under it. +- Chat version 22, `signedRelayInvVersion`; `currentChatVersion` is 22. From this version of the owner's range, an invitation without both claims and a verified signature is ignored by `xGrpRelayInv` with a message error, and no request is created. An invitation from an owner below this version is accepted unsigned. +- The group id claim is written to `groups.relay_request_public_group_id` with the other relay request fields, and read by `getRelayRequestData` into `RelayRequestData`, which gains `reqPublicGroupId :: Maybe B64UrlByteString`. `group_profiles.public_group_id` holds only ids taken from link data. +- Migration `M20260925_badge_bindings`, SQLite and Postgres: `ALTER TABLE groups ADD COLUMN relay_request_public_group_id BLOB;`, `BYTEA` on Postgres. +- The key claim is written to the owner's `group_members.member_pub_key`. +- The owner member is read by `processRelayRequest` in the transaction that reads the group, and passed to `getLinkDataCreateRelayLink` and `acceptOwnerConnection`. +- The request is failed by `getLinkDataCreateRelayLink`: + - when the group id claim differs from the link's entity id; + - when the key claim differs from the key of the link data owner with the owner's member id. +- Each comparison is skipped for a request without that claim. +- The public group id and the owner keys are written from the link data by `updateRelayGroupKeys`. + +**13. Tests** — `ChatTests/Profiles.hs`, `ChatTests/ChatRelays.hs`, `ChatTests/Groups.hs` + +- Direct: the badge is shown after a request to an address with and without ratchet keys, after accepting, after joining a one-time link, after a retried join, and after a profile update. +- A proof bound to another chat is ignored, and the stored badge is kept. +- P2p group: the joiner's badge is shown at the host at the request and after `INFO`; an introduced member's badge is shown from the introduction; the badge is shown on both sides of an invitation via contact. +- Invitation via contact: the invitee's badge is stored on the membership at the host, and a later member receives it in the introduction. +- Channel: the owner's badge is shown at a subscriber and the subscriber's badge at the owner, forwarded by the relay (`testChannelMemberBadges`). +- Channel: a relay invitation with an owner key pair that differs from the link data is failed by the relay, and the relay stays invited (`testChannelAddRelayOwnerKeyMismatch`). +- Link data: the badge is shown from an invitation link under `PHLink`, an address, and an address that gets its first short link. +- Member proofs: the stored proof is deleted when a profile without a badge is received, for a member joined by a group link and for a member with a contact. +- Peers below `relayWebCapVersion`: `XGrpAcpt` to an inviting host and `XGrpMemInfo` to a joining member include the badge unsigned, the handshakes complete, and neither receiver stores the proof. + +The profile update and app changes are not part of this plan; they were merged in #7663. + +`PHTest` proofs are generated only by released clients. For a connection prepared by a released client and joined after the update, the badge is presented only when its ratchet was stored by an earlier attempt; a retried request to an address prepared by a released client is sent without a badge. + ## Out of scope -- Moving the direct chat profile proof to `PHChat`. - Requiring an expiration in the description proof, once servers grant one. diff --git a/plans/2026-09-13-parallel-tests.md b/plans/2026-09-13-parallel-tests.md new file mode 100644 index 0000000000..461c970b84 --- /dev/null +++ b/plans/2026-09-13-parallel-tests.md @@ -0,0 +1,81 @@ +# Parallel test execution + +Every test gets its own ports and its own directory, so hspec runs the tree +concurrently in one process with `parallel` and `--jobs`. + +## Decisions + +- `TestParams` gains `portBase :: Int`. A pool of bases `[7000, 7010 .. 8990]` + is created in `main`; `testBracket` takes a base for the test and returns it + after. Ports of a test: SMP `base + 1`, XFTP `base + 2`, second SMP `base + 3`, + remote host `base + 4`. +- `TestCC` carries `testParams`. `class HasTestParams` with instances for + `TestParams` and `TestCC` lets helpers take either, so closure tests reach + the environment through a client: `tmpFile bob "test.jpg"`, `smpPort alice`, + `withXFTPServer alice`. +- Client configs keep the literal ports 7001, 7002 and 7003 as the logical + layout. `startTestChat_` maps them to the test's ports in `smpServers`, + `xftpServers` and `shortLinkPresetServers`. Expected output uses the real + ports through `smpServerStr`, `xftpServerStr`, `smpPort`, `xftpPort`, + `smpPort2`. +- Servers take the test: `smpServerCfg ps`, `withSmpServer ps`, + `withSmpServerAndNames ps`, `xftpServerConfig ps`, `withXFTPServer ps`. + The XFTP server stays under test control, since tests exercise its restarts. + Its files and store log are under the test directory. +- `tests/tmp` is created once around the whole run (`withTmpFiles` in `main`) + and each test works in `withTempDirectory "tests/tmp"`. Paths in tests are + `tmpDir` and `tmpFile` of the client or params. +- `xftpCLI` runs the test binary as a subprocess with the first argument + `xftp-cli`, because `withArgs` and `capture_` are process-global. +- Test suite `ghc-options` gain `-rtsopts "-with-rtsopts=-N"`. +- `parallel` wraps the tree. Sequential remain: schema dump, API docs, + "Save query plans" (last item, reads the maps after every test). +- Postgres: the database is created once (`beforeAll_`/`afterAll_`); the + client schema prefix includes the test directory name. +- Bots started through `directoryService`, `badgeService` and + `simplexChatCore` take `coreOptions` and the config from `testPortsCfg`. +- `TestTerminal` wraps the virtual terminal and writes the cursor row to + `termQ` on `PutLn`. +- The "no output left" check runs in the bracket body; `stopTestChat` stops + the client in a separate thread with a 60 s limit and keeps the store open + after the limit. +- Per-test limit 180 s; the "channels" group is `sequential`; `@@@` waits + 500 ms. TTL and timed-message tests use multi-second TTLs; ratchet + desynchronization helpers turn receipts off. + +## Status + +Implemented; the suite builds. Runs so far: file tests, 39 examples in 111 s +with `-j 8` on 4 cores at -O0, one timing failure per run, a different test +each time. "error receiving file" (`testXFTPRcvError`) fails intermittently +even alone with `chat db error: SERcvFileInvalidDescrPart`: `appendRcvFD` +receives a description part for a description that is already complete, so +the part arrives twice. The first block stops bob right after alice's upload +completes, without waiting for bob to process the description, which is the +likely source of the duplicate after his restart. The race predates this +change; `-N` and load make it more likely. + +Direct, schema dump, protocol and names groups: 178 examples in 331 s with +`-j 6`, 11 failures. Two are environmental on this machine: the schema dump +cannot overwrite root-owned `chat_schema.sql`, and `encrypt/decrypt database` +needs `direct-sqlcipher +openssl`, which CI sets and the local build lacks. +The rest pass alone and fail under load: expiration and timed-message tests +with second-scale TTLs, the client-timeout retry test, and cases where a +line already consumed reappears at the end of the test, which points at the +window diff in `readTerminalOutput` under batched terminal updates. + +Profile tests at the default job count (4 cores): 105 examples in 439 s, +2 failures, both timed-message tests with second-scale TTLs. + +## Mechanics + +- `tests/ChatTests/DBUtils/*.hs`: `portBase` field. +- `tests/ChatClient.hs`: `HasTestParams`, port and path helpers, server + configs and brackets as functions of the test, port mapping in + `startTestChat_`, `withTmpFiles` without removal per test. +- `tests/Test.hs`: port pool, `xftp-cli` subcommand, `parallel`, `withTmpFiles` + around `hspec`. +- `tests/SchemaDump.hs`: `withTmpFiles` wrappers removed. +- `tests/ChatTests/Utils.hs`: `xftpCLI` subprocess. +- Test modules: literal `tests/tmp` paths, server ports and XFTP brackets + rewritten through the helpers. diff --git a/plans/2026-09-20-passphrase-autofill.md b/plans/2026-09-20-passphrase-autofill.md new file mode 100644 index 0000000000..a763a444e6 --- /dev/null +++ b/plans/2026-09-20-passphrase-autofill.md @@ -0,0 +1,56 @@ +# Passphrase fields not recognised by password managers (Android) + +PR #7612, branch `nd/passphrase-autofill`. + +## Problem + +> The passphase isn't detected as a password box for any password manager so it refuses to autofill. + +`PassphraseField` (`DatabaseEncryptionView.kt`) was never classified as a password, so no manager offered to fill or save the database passphrase. While verifying this, a second defect turned up: the raw passphrase could be copied to the clipboard from the selection toolbar and through accessibility `ACTION_COPY`. + +## Root cause (Compose 1.8.2, from bytecode) + +- Semantic autofill is enabled (`ComposeUiFlags.isSemanticAutofillEnabled = true`). `PopulateViewStructure.populate` builds each node from semantics. It sets autofill hints only from `SemanticsProperties.ContentType`, and `inputType = 0x81` plus `dataIsSensitive` only from `password()` or a `"password"` hint. +- The legacy `BasicTextField` never sets `ContentType`. It sets `password()` only when `visualTransformation is PasswordVisualTransformation`. The hand-rolled `VisualTransformation { … }` lambda fails that check, so the field reached managers with no hints and no password `inputType`. +- The same `is PasswordVisualTransformation` check gates Copy/Cut in the selection toolbar and the `CopyText`/`CutText` accessibility actions. Both copy `value.getSelectedText()`, which is the raw passphrase. + +## Fix + +`PassphraseField`: +- uses `PasswordVisualTransformation('*')`. The mask looks the same, and it restores `password()` semantics and the copy/cut gates. +- takes `contentType: ContentType? = ContentType.Password` and applies it with `.semantics { if (contentType != null) this.contentType = contentType }`. + +`newPasswordContentType()` (expect/actual in `platform/Modifier*.kt`) returns `NewPassword + Password` on Android and `NewPassword` on desktop, where `ContentType` has no `plus`. Desktop has no OS autofill, so it is inert there. + +| Screen | Current | New | Confirm | +| --- | --- | --- | --- | +| Change passphrase (`DatabaseEncryptionView`, current field shown) | `password` | `newPassword` | `newPassword` | +| First passphrase (`DatabaseEncryptionView` with no current field, `SetupDatabasePassphrase`) | — | `newPassword`+`password` | `newPassword`+`password` | +| Unlock / migrate (`DatabaseErrorView`, `MigrateToDevice`, `MigrateFromDevice`) | `password` | — | — | +| Hidden profile password (`HiddenProfileView`), unhide/delete (`UserProfilesView`) | none | none | none | + +Why the hints vary: +- **First passphrase:** KeePassDX does not recognise a form whose every field is `newPassword`, so these fields also carry `password`. +- **Change form:** adding `password` to the new fields here gives three identical `password` fields, and the manager stops offering to save. +- **Hidden profile:** these fields carry no `ContentType`, so no password manager offers to save them. A stored entry would reveal that the hidden profile exists. `AndroidAutofillManager.onEndApplyChanges` commits the session, which raises the save prompt, only after the last node carrying `ContentType` leaves the screen (`isRelatedToAutoCommit` = has `ContentType`), so leaving these screens never commits. Compose 1.8.2 cannot mark a node not important for autofill (`populate` never calls `setImportantForAutofill`). The fields still carry `password()`, so a manager may still offer to fill them. + +The app never calls `AutofillManager.commit()`. Committing on submit was tried and reverted: it ended the session while the fields were still shown, and autofill stayed dead until the app restarted. + +## Verification + +- **Semantics** (desktop Compose test `~/passphrase-review/semtest`, a two-field A/B): with the fix the node has `ContentType`, `Password = true`, and no `CopyText`. Without the fix it has no `ContentType`, `Password = false`, and `CopyText` present. `EditableText` is masked either way; autofill never received the raw value. +- **Device, structure** (stub `AutofillService` dumping `AssistStructure`, `~/passphrase-review/harness`, on an earlier `newPassword`-only build): the passphrase fields arrived as `EditText` with `inputType=0x81` and their hints. +- **Device, KeePassDX:** the hint combinations in the table were settled by testing with KeePassDX. It fills and offers to save on the change and first-passphrase forms, and the save prompt appears when the screen is closed. +- **Not yet checked on a device:** the hidden-profile screens (no `ContentType`) showing no save prompt. + +## Limitations + +- KeePassDX offers to fill only the last password field in the form (Confirm rather than New). New and Confirm emit identical hints and `inputType`, so this has to be fixed in KeePassDX. +- The framework still commits when the activity finishes (`AutofillManager.onActivityFinishing`) unless the service set `FLAG_DONT_SAVE_ON_FINISH`. A session opened by focusing a hidden-profile field could therefore still prompt to save if the activity finishes before that session ends. +- With a hardware keyboard or mouse, Ctrl+C and the Android right-click menu still copy, because those paths don't check the transformation (an upstream Compose gap). Only `BasicSecureTextField` blocks copying outright. +- The Compose text-toolbar "Autofill" item does nothing for the legacy `BasicTextField`, because `requestAutofillAction` is never assigned. Also upstream. + +## Out of scope + +- `DefaultConfigurableTextField` (`DefaultBasicTextField.kt:163–166`), which backs the SOCKS proxy password (`NetworkAndServers.kt:525`), has the same lambda and no `ContentType`. It has the same clipboard exposure, and the same two-line fix applies. +- iOS `PassphraseField` (`DatabaseEncryptionView.swift:304`) is a `SecureField` without `.textContentType`, which is the same gap. diff --git a/plans/2026-09-23-fix-attachment-sheet-under-keyboard.md b/plans/2026-09-23-fix-attachment-sheet-under-keyboard.md new file mode 100644 index 0000000000..672e59aed5 --- /dev/null +++ b/plans/2026-09-23-fix-attachment-sheet-under-keyboard.md @@ -0,0 +1,60 @@ +# Fix attachment options hidden under the keyboard + +## Problem + +On Android, when the keyboard is already open and the user taps the attachment +(paperclip) button in a chat, the sheet with the Camera / Image / Video / File +options opens partly or fully under the keyboard. With the keyboard closed it opens +correctly. + +## Cause + +The sheet is a Material `ModalBottomSheetLayout` (`ChatView.kt`, `ChatLayout`) whose +state is created in `ChatView` as +`rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)`, leaving +`skipHalfExpanded` at its default `false`. + +The sheet content, `ChooseAttachmentView`, pads itself with +`.navigationBarsPadding().imePadding()`, so while the keyboard is open the sheet's +height is the buttons row plus the keyboard height, and the buttons are at the top of +the sheet. + +In Material 1.8.2 (`androidx.compose.material:material`, `ModalBottomSheet.kt`): + +- `modalBottomSheetAnchors` adds a `HalfExpanded` anchor at `fullHeight / 2` whenever + `!isSkipHalfExpanded && sheetSize.height > fullHeight / 2`; +- `show()` animates a hidden sheet to `HalfExpanded` when that anchor exists, and to + `Expanded` otherwise. + +With the keyboard closed the sheet is short, there is no `HalfExpanded` anchor, and +it opens `Expanded`. With the keyboard open the sheet is taller than half the +layout, so it stops at `HalfExpanded`: its top is at the middle of the screen, and +the buttons row below it lands under the keyboard's top edge. + +## Fix + +Pass `skipHalfExpanded = true` to the attachment sheet's +`rememberModalBottomSheetState`. `show()` then always goes to `Expanded`, so the +sheet's bottom sits at the bottom of the layout and the `imePadding` places the +buttons directly above the keyboard. + +The half-expanded state has no use for this sheet: it holds a single row of buttons +and no scrollable content. + +## Scope / non-goals + +- Only the chat attachment sheet is changed. The other `ModalBottomSheetLayout`s + (`GroupProfileView`, `UserProfileView`, `AddGroupView`, `AddChannelView`, + `WelcomeView`) use the same default state but are not part of this report. +- The two preview usages of `rememberModalBottomSheetState` in `ChatView.kt` are + left as they are. +- Desktop: the attachment sheet is shared code, but desktop has no software + keyboard inset, so the sheet is never taller than half the window and behaviour + is unchanged. +- iOS was not checked; it does not use this code. + +## Verification + +Debug arm64 APK built from this branch; on a device, open the keyboard in a chat +and tap the attachment button: the options are shown directly above the keyboard. +With the keyboard closed the sheet opens as before. diff --git a/plans/2026-09-24-badge-buy-in-browser.md b/plans/2026-09-24-badge-buy-in-browser.md new file mode 100644 index 0000000000..ec79f0564e --- /dev/null +++ b/plans/2026-09-24-badge-buy-in-browser.md @@ -0,0 +1,116 @@ +# Buying a badge in the browser + +The web page already sells badge codes. This makes the app the way in without making the app a shop: the app opens the page, the page runs the checkout it already has, and at the end hands the code back through a deep link instead of putting it on screen. The buyer never sees a code, and the code purchase itself is untouched. + +Wireframes: `plans/sketches/2026-09-23-badge-in-app-checkout.excalidraw`. Panel ids below (A1, B3, D2…) refer to it, and the screens are drawn there rather than described again here. + +## Scope + +**Part 1** is everything in the wireframes except recurring payments: the browser lane on both builds, and the store lane's one-time product. Monthly and Annual stay on B3 and keep exactly the behaviour they have now — a real store transaction followed by a diagnostic alert, nothing sent anywhere. They must not reach the service, which cannot yet add months to a badge that already exists. + +The two lanes of part 1 are independent and unequal: the browser lane needs nothing from the service and can ship on its own, while the store lane cannot start until `purchaseBadge` accepts store receipts. + +**Part 2** is subscriptions, outlined at the end. + +Everything in the app sections below is both platforms unless it says otherwise. + +## Order of work + +1. **The renames**, on their own. Both screens are unreachable today, so nothing visible changes and the diff is strings and two titles. +2. **The page** — the `app` parameter, the second-screen start, both endings. Nothing sends the flag yet, so this is inert whenever it lands. It sits on the unmerged badge-service branch, which decides where it gets committed. +3. **The app's browser lane** — Buy in browser, the link handler, A6, and the desktop pair. End to end once 2 is deployed; before that it degrades to today's flow, where the buyer carries the code by hand. +4. **`purchaseBadge` with store receipts**, which depends on none of the above and can run alongside it. +5. **The app's store lane**, last, because nothing in it completes without 4. + +(*) **Desktop deep links** sat outside this order entirely. + +## The page + +Three changes, all in `apps/simplex-badge-service/web`. + +**Start at the second screen.** The page already routes on the fragment, and `#/tier` is one of its own step hashes, landing on Choose your badge — the screen after its own Support SimpleX, which the app has just shown. But `landingIndex()` compares the whole hash, so `#/tier?app=true` matches no step and falls to the welcome panel. Parsing the flag's tail is what makes this work; it is not free. + +The app opens the page at its own host, `https://badges.simplex.chat/#/tier?app=true`. Not `simplex.chat/badges/`, which the app links to today: that is an Eleventy page whose layout is a bare `` to a fixed URL, so the fragment does not survive it. The redirect stays as it is for people who type it. Both platforms should hold the page URL as one constant, since a dev deployment of the service has its own page and the app has to be able to point at it. + +**Carry an `app` flag through the flow.** It rides in the fragment beside the step — `#/tier?app=true`, or `?app=desktop` — rather than in the query, because a fragment is never sent: the service learns nothing about which visitors came from the app, and it is stripped from the `Referer` when the page hands off to a payment provider. Nothing server-side needs it, and both things it decides are rendering. + +Only the ending needs it, and getting it there takes two carriers, because nothing single-handedly spans the flow. The session record holds it from the first load to checkout. At `created` the session is cleared and the URL is rewritten to a relative `?order=`, which drops the fragment, and a card payment returns to `origin + pathname` with neither — so the flag is then stamped onto the order record, beside the browser-only fields it already keeps, and the ending reads the order alone. An order resumed in another browser has no record, falls back to the plain page, and shows the code; that is correct. Keeping the flag in the session past checkout instead would be smaller and wrong: it would outlive the purchase, and it could not be cleared on a flagless load, because a reload and the card return are both flagless. + +**Replace the final screen for `app=true` (A5).** Try the deep link on load, then offer *Return to SimpleX* with *Show code* under it. `Show code` reveals the existing `codeIssued` content, unchanged — which means the code must still be minted and saved in the browser exactly as now. For `app=desktop` (D2), keep `codeIssued` as it is and change only the "Redeem it in the app" line to say the screen is already open. With no flag, nothing changes. + +## The link + +`simplexchat:/badge/code/` — a scheme of its own, not a path added to `simplex:`. That costs a registration on every platform, including the two where `simplex:` was already free, and buys a badge link that cannot be mistaken for a connection link and an existing dispatch left alone: the URL arrives at the same entry point the app already has, branches on scheme before the path switch that feeds `planAndConnect`, and never reaches it. + +Two things the code must get right: + +The link is a bearer token. Anything that can read it can redeem the badge — no worse than a code on screen, but it must not be logged. iOS logs every incoming URL in `SimpleXApp.onOpenURL` today and Kotlin should be checked for the same; those lines have to stop short of the code. Any web page can send one, so the screen it opens should do nothing but redeem. + +A link may arrive when no profile is active, or for a different profile than the one that bought it. Redeem into the active profile, as the redeem screen does, and let the existing errors speak if the code is spent. + +## The app: non-store (A1, A6) + +On Support SimpleX, *Get your code* becomes the primary button *Buy in browser* and *Redeem badge code* drops to the text button. The tap must open the browser directly, not through `openExternalLink`, which alerts for anything that is not a SimpleX connection link. The info button stays as it is: the TODO in that file proposing to swap it for *Why SimpleX is built* should not be followed. `chooseLevelButton()` is never called in these builds — there is no in-app purchase here at all. + +A6 is the one new screen: a title, a spinner, and a call to the existing redeem API with the code from the link. It is reachable only from the link, never from navigation, and a link can arrive from anywhere in the app — present it the way the badge sheet is already presented from an alert. Its failures are the redeem screen's failures and should use the same alerts. On success the app lands on Your badge by the path it already takes. + +## The app: store builds (B1–B5) + +B1's primary becomes *Choose your badge* — `chooseLevelButton()`, which already exists and is called by nothing. Its text button is *Buy in browser* where the store rules allow it and *Redeem code* where they do not, so the slot is never empty and codes are always reachable. Define that predicate once and use it for both the button and B2's — the rule is expected to be wider than the crowdfunding banner's and is not settled, so it should be one thing to change later. + +B2 (`BadgesChooseBadgeView`) gains *Redeem code* only when B1's slot is taken by *Buy in browser*. The privacy link stays on B1 in both builds and does not move here. + +B3 (`BadgesHowLongView`) loses its Pay button to *Continue*; `purchase()` moves to B4, which is new. Three of B5's outcomes need building: a verified transaction becomes a funded-not-issued state, `.pending` becomes an awaiting-the-store state, and an unverified one is refused with an alert and never sent. Both of those states belong on `BadgeState` and both must hold the Support SimpleX slot, because a consumable can otherwise be bought twice. Cancelled does nothing, and other billing errors keep today's alert. + +Two invariants that are easy to miss. A store transaction must not be finished until the credential is stored — `BadgeStore` says so in a comment already and does the opposite because nothing is delivered yet. And both platforms need reconciliation at launch (`Transaction.updates`, `queryPurchasesAsync`), because an outcome can arrive while the app is closed. + +## Desktop (D1, D2) + +Where the scheme is not registered, there is no deep link: *Buy in browser* opens the page with `app=desktop` and the Redeem code screen at the same time, so the code has somewhere to go the moment it appears. This is also the fallback for any platform where the scheme turns out not to work: nothing else in the app depends on the link existing. + +## Desktop deep links (*) + +This was optional and off the critical path, and it blocked nothing, since D1 works without it. + +The plan is `plans/2026-09-28-desktop-deep-links.md`. The feature is implemented, and verification on real machines is pending. Desktop joins lane A wherever the scheme is registered, and D1 is the fallback rather than the design. + +Before that work, nothing registered a scheme on desktop. `appOpenUrl` was wired in commonMain with no desktop implementation behind it, and `desktop/build.gradle.kts` declared Deb, Dmg, Msi and Exe with no protocol entry. Registration is per-OS, three separate small jobs: `CFBundleURLTypes` in the bundle plist plus `Desktop.setOpenURIHandler` on macOS, registry keys under `HKCU\Software\Classes` on Windows (the app now writes them at runtime), and a `.desktop` entry with `MimeType=x-scheme-handler/simplexchat` on Linux. None of them covers an unpackaged run, so the fallback stays either way. + +Getting the URL to an app that is already running was the part that half existed. `SingleInstance.kt` took a file lock and used a watched file to bring the running instance forward on a second launch; the implementation gives that channel a payload rather than inventing IPC for it. + +## The service + +The browser lane needs nothing. The store lane needs `purchaseBadge` to accept a store receipt and verify it — Apple's JWS offline, Google's token through the Publisher API, per `docs/protocol/badges-rpc.md`. No provider adapter for either exists today. + +Two properties matter more than the plumbing. It must be idempotent per signing key, since the app will retry. And only a store's verdict about a purchase may be a plain refusal: the client consumes a purchase answered `receipt_invalid`, so a store that has merely not answered — or that does not yet know a token it has just issued — must be answered retryably, or a paid badge is thrown away. Junk tokens from tampered clients are the cheap side of that trade: each costs one request per client trigger, nothing loops on the service, and Play refunds an unacknowledged purchase after three days. `docs/protocol/badges-rpc.md` has the classes. + +`badges-rpc.md` also specifies `getBadgeCatalog` and `getBadgeInvoice`. Neither is needed by anything here and neither should be built: the page owns the catalog and the invoice. + +## Naming + +Two renames: *Your level* becomes *Choose your badge* with the page's lede, and the duration screen's title stops being the level name and becomes *How long?* with the chosen badge on the line below. This is the page's vocabulary; the app adopts it so the two halves of one purchase read the same. + +The views take their screens' names too, in a commit of their own: `BadgesYourLevelView` becomes `BadgesChooseBadgeView` and `BadgesPayView` becomes `BadgesHowLongView`, matching the directory's habit of naming a view after its title. `BadgeLevel` and `BadgePeriod` are domain types rather than views and keep their names — and `BadgeLevel` is marked for replacement by the purchase API's own types, so renaming it would be churn. Kotlin resource ids change along with their values — `badges_your_level_title` becomes `badges_choose_your_badge_title` and so on. Keeping an id while changing its English would leave every translation of it saying the old thing and still being shown, since a changed base value does not invalidate a translation; renaming retires them and gets the string translated afresh, which is what iOS does for free, its literals being their own keys. An id removed from base has to be removed from each translation file too, or the build fails formatting them. None of this costs anything today: no `badges_*` string has been through a translation round, so `base/strings.xml` is the only file that holds any. It stops being free the moment they are translated. + +## Tests + +The page has screen and routing tests; extend those rather than starting a parallel suite, and cover all three cases, including no flag at all. No UI tests on the app side — verify those by review. + +The store purchase needs end-to-end coverage and `BadgeServiceTests` is already the place: it runs a real client against a real service over SMP and covers redeeming, issuing, renewal and the worker. Everything from `APIPurchaseBadge` rightwards belongs there, with the receipt an opaque blob and verification behind a test provider that accepts one known token — the same seam the crypto and card providers already sit behind. Worth covering: a valid receipt issues a credential; the same receipt twice returns that same credential; an unknown token is refused and the refusal is not retryable; a receipt whose purchase key is not the signer is refused, as the code path is already tested for. + +The store SDK call itself cannot be automated. StoreKit needs a configuration file on a device, Play needs a real billing connection, and `.pending` needs a sandbox family account with Ask to Buy. That part is QA, and `useBadgeTestProducts` already exists for walking the Kotlin flow without Play Console products. + +## Comments + +Default to none. Add one only where behaviour or a consequence cannot be carried by names and structure — the transaction-finish ordering, the unlogged code, the retryability rule above are the kind of thing that earns one. Do not comment each function, do not restate the code, and do not reference this plan, a panel id or a ticket in the source. + +## Open questions + +- **Will a browser follow the scheme without a gesture?** The attempt is made from a hidden frame, so a refusal stays inside the frame instead of replacing the screen with an error page and putting the code into history. Expect it to do nothing on iOS Safari, which has ignored custom schemes in frames for years, and on Chrome, which blocks an external-protocol launch with no user gesture — and a payment settling minutes after the last tap has none. So *Return to SimpleX* is the real path and the automatic attempt is a bonus where it happens to work. What remains is to try it on real devices once the app registers the scheme. +- **Where the browser button is allowed.** Which markets, and whether the same answer governs both builds. + +## Part 2: subscriptions + +Monthly and Annual become real purchases. The service side is the work: a subscription renews without the app present, so the badge has to gain months from a provider notification rather than from a `purchaseBadge` call, which means Apple's server notifications and Google's Pub/Sub, and a way to add months to a badge that already exists. The app side is smaller but not only copy: `BadgeState.renewsAt` and `willRenew` are hardcoded to nothing today and have to become real, and `BadgesYourBadgeView`'s Ends section says prepaid months have no billing date, which stops being true and needs a second wording. + +The browser lane never gains subscriptions: the page sells prepaid months and says so. diff --git a/plans/2026-09-26-free-core-strings-in-jni.md b/plans/2026-09-26-free-core-strings-in-jni.md new file mode 100644 index 0000000000..effd2374a7 --- /dev/null +++ b/plans/2026-09-26-free-core-strings-in-jni.md @@ -0,0 +1,103 @@ +# Free strings returned by the core in the JNI bindings + +## Problem + +On desktop and Android, resident memory grows for as long as the app runs and is +only released by restarting it. On a profile with ~200k connections it reaches +several GB within a day; the growth continues while the app is idle. + +None of it is visible to either heap tool. The JVM heap stays small, and the +Haskell heap accounts for its own region only — the memory is in the C allocator, +which neither profiler walks. + +## Cause + +Every `chat_*` entry point returns a string that the core allocates with `malloc` +and hands to the caller. `Simplex.Chat.Mobile.Shared` is explicit about it: + +```haskell +newCStringFromLazyBS :: LB.ByteString -> IO CString +newCStringFromLazyBS s = do + ... + buf <- mallocBytes (len + 1) +``` + +The caller owns that buffer. iOS honours the contract — `SimpleXChat/API.swift` +calls `free(c)` after copying each response into a Swift string. The two JNI +bindings never do. + +Both of them copy the bytes into a Java string and then drop the pointer: + +- desktop `decode_to_utf8_string` wraps the buffer in a `DirectByteBuffer`, + decodes it through `Charset.decode`, returns the resulting `String`, and + deletes only the JNI local references; +- Android calls `NewStringUTF(env, chat_*())` inline at every entry point, so the + pointer is never even bound to a variable. + +`chatReadFile` leaks the same way on both platforms, after `SetByteArrayRegion` +has already copied the payload. + +So every command, every received event, and every file read leaks its whole +response. Nothing bounds it: the buffers are unreachable from both runtimes, the +Java string is a copy, and no code path retains the original. + +### Measurements + +A direct-FFI harness calling `chat_send_cmd_retry` in a loop, outside the JVM, +separates the two cases cleanly: + +| harness | behaviour | +|---|---| +| response pointer dropped (current bindings) | +5 KB per call, linear, no plateau | +| response pointer freed | rises to 138 MB and stays flat | + +Per interface event the mean response is ~3.9 KB, so the rate follows activity +rather than connection count. + +On a desktop client running the affected profile, `/proc//smaps` attributes +roughly 1.0 GB to glibc's non-main arenas — sixteen mappings of 63-64 MB, which is +`HEAP_MAX_SIZE` on x86-64, aligned to 64 MB boundaries. Those pages are counted as +touched, not merely reserved. Over the ~10 hours of uptime that is on the order of +100 MB/hour, against a Haskell heap of 1.70 GB and a JVM heap of 328 MB in the +same process. + +## Fix + +Free each buffer once its contents have been copied. + +Desktop already has a single funnel, `decode_to_utf8_string`, which every entry +point goes through, so one `free` covers all of them. Android has no such funnel +because `NewStringUTF` is called inline, so the fix adds `decode_and_free` and +routes the sixteen call sites through it. `chatReadFile` frees its buffer directly +on both platforms. + +Android cannot include `stdlib.h` — the file defines a `reallocarray` stub that +conflicts with it — so `free` is declared on its own. + +The change is confined to the two binding files and does not alter what either +function returns. + +### Why this is safe + +In every case the copy is complete before the buffer is freed: + +- `Charset.decode` produces a `CharBuffer` whose backing array is freshly + allocated, and `toString` copies again into the `String`; +- `NewStringUTF` copies into a new Java string; +- `SetByteArrayRegion` copies into the `byte[]` before either `free` runs. + +Nothing retains the original pointer. On desktop the `DirectByteBuffer` is a view +over it, but it is a JNI local reference deleted in the same function and never +escapes to Java. + +Note that the *input* direction was already correct by accident: the bindings pass +the `malloc`ed buffers from `encode_to_utf8_chars` to `ReleaseStringUTFChars`, +which is not the matching deallocator but reaches `free` through HotSpot's +`FreeHeap`. That mismatch is left alone here. + +## Verification + +- Desktop AppImage with the fix, on a fresh profile: flat at 806-808 MB RSS + through 35 minutes of traffic followed by 10 minutes idle. +- Both `free` calls confirmed in the shipped binary by disassembling + `libapp-lib.so` out of the AppImage. diff --git a/plans/2026-09-27-android-desktop-storage-view.md b/plans/2026-09-27-android-desktop-storage-view.md new file mode 100644 index 0000000000..fbeb1be6f8 --- /dev/null +++ b/plans/2026-09-27-android-desktop-storage-view.md @@ -0,0 +1,53 @@ +# Android, desktop: storage breakdown in developer options + +## Problem + +When the Android or desktop app takes a lot of space, there is no way to see from inside the app what is using it. The Database screen shows only the count and size of received files (`directoryFileCountAndSize(appFilesDir.absolutePath)`, `Utils.kt`), and that helper is not recursive: it adds up `File.length()` of the direct children only. + +The databases, their `-wal`/`.bak` copies, wallpapers, core temp files, migration leftovers, remote-host data and preferences are all invisible. On one desktop profile, for example, the `.bak` copies left by database migrations were 5.6 MB of the 13 MB of database files. + +## Cause + +iOS has had this screen since #5529 (`apps/ios/Shared/Views/UserSettings/StorageView.swift`, under Developer options), but it was never ported to Kotlin. + +## Fix + +A Storage item in Developer options (shown only when "Show developer options" is on) opens `StorageView`. The screen lists each top-level entry of the app's storage folders with its recursive size, largest first, and a total per folder. + +Folders measured: `dataDir`, `preferencesDir` and `tmpDir`. Any folder that is the same as, or inside, another one is dropped, so nothing is counted twice: + +| Platform | Folders shown | +|---|---| +| Android | `dataDir` only. `shared_prefs` and `app_temp` are inside it, and so are the databases, `files/`, `cache/` (exports) and `app_temp/remote_hosts`. | +| Linux, macOS | `$XDG_DATA_HOME/simplex`, `$XDG_CONFIG_HOME/simplex`, `java.io.tmpdir/simplex` | +| Windows | `%AppData%\SimpleX` (config and data are the same folder), `java.io.tmpdir\simplex` (usually `%TEMP%`) | + +Like iOS, it shows whatever entries actually exist rather than named categories. Named categories would need to be kept in sync with the path helpers and would hide anything unexpected, and unexpected entries (such as `.bak` files) are what this screen is for. + +It improves on iOS in four ways: + +- It measures on `Dispatchers.IO`; iOS measures on the main thread in `.onAppear`. +- It sorts rows by size; iOS iterates a dictionary, so the order is random. +- It shows a total per folder. +- A file or folder inside the walk that cannot be read is logged and skipped, and a storage folder that cannot be listed shows as empty. On iOS, a failure to read one entry's attributes ends the whole walk. + +The walk uses `Files.walkFileTree` (API 26 = minSdk) without `FOLLOW_LINKS`, as iOS's `FileManager.enumerator` does not follow links either: a symlink is counted as the link itself, so it cannot loop, escape to `/` (Windows directory junctions are not treated as links by the JDK, but the app never creates them), or (on Android) count the APK's native libraries through a `lib` link that the system may create in the data folder. + +Sizes are file lengths (`BasicFileAttributes.size()`). iOS uses allocated size, which has no portable equivalent on the JVM, and length is what the existing Database screen reports. + +## Verification + +- A standalone harness copying the walk logic, run against: + - a symlink loop and a link to `/`: not followed + - an unreadable folder: logged, the walk continues + - a nested folder and a duplicate folder: dropped + - a missing folder: shown as empty +- On a real 187 MB desktop profile, the walk took 0.8 s and matched `du -sb` minus the 4 KB that `du` also counts for each directory. +- A desktop AppImage run on a separate test profile showed all three folders, with sizes matching `ls`. + +Top-level entries are listed with `Files.newDirectoryStream` rather than `File.listFiles`, so each name stays a `Path` built from the raw bytes. Going through `String` and back with `File.toPath()` loses names that are not valid in the JVM's file-name encoding (any non-ASCII name under a C locale on Linux, e.g. a received file decrypted into `tmpDir`), which then either throws `InvalidPathException` or points to a file that does not exist. A root that cannot be listed (missing or unreadable) is logged and shown as empty. + +## Not included + +- The desktop Postgres build keeps its database on the Postgres server, so it is not in the total. +- Leaving the screen does not cancel a walk in progress. The result is simply discarded. diff --git a/plans/2026-09-28-desktop-deep-links-report.md b/plans/2026-09-28-desktop-deep-links-report.md new file mode 100644 index 0000000000..08561f6808 --- /dev/null +++ b/plans/2026-09-28-desktop-deep-links-report.md @@ -0,0 +1,150 @@ +# Desktop deep links: report + +Packaged desktop builds on macOS, Windows and Linux receive `simplexchat:` links from the badge page, and `simplex:` connection links, with the app closed, running or hidden in the tray. Unit tests and builds pass; no packaged build has been run on real machines yet, so the manual checklist below is open. + +The plan is `plans/2026-09-28-desktop-deep-links.md`. + +## Flow + +``` +page: Return to SimpleX → simplexchat:/badge/code/ + macOS Apple Event → installOpenUriHandler → openDesktopLink + Windows, Linux new process main(args) → linkFromArgs + lock acquired → chatModel.appOpenUrl (cold start), then the watcher starts + lock taken → simplex.show with the link → running app → openDesktopLink +openDesktopLink: chatModel.appOpenUrl = remoteHostId() to link, then showWindow() +App.kt LaunchedEffect → connectIfOpenedViaUri → openAppLink → BadgesRedeemLinkView (asks before redeeming) +``` + +## Registration + +| Build | Registered by | What is written | Registered when | +|---|---|---|---| +| macOS `.dmg` | Info.plist, at build | `CFBundleURLTypes` with schemes `simplexchat` and `simplex` in `desktop/build.gradle.kts` | copied to /Applications; LaunchServices picks it up | +| Windows `.msi`, `.exe` | the app, each start | `HKCU\Software\Classes\simplexchat` and `\simplex`: `URL Protocol`, `DefaultIcon`, `shell\open\command = "" "%1"`; ProgIds `SimpleX.simplexchat` and `SimpleX.simplex` with the same command; `HKCU\Software\chat.simplex.app\Capabilities` with `URLAssociations`, listed in `HKCU\Software\RegisteredApplications` | first launch per user; rewritten when a value is missing or the command's program is gone, not while another installed program, per-user or machine-wide, holds it; registered when Windows (`AssocQueryStringW`) names this exe as the program for `simplexchat:` | +| Linux `.deb` | `make-deb-linux.sh`, at build | jpackage's `simplex-simplex.desktop` gets `Exec=... %u` and `MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;` | install; postinst `xdg-desktop-menu install` refreshes the MIME cache | +| AppImage | the app, each start; also the bundled entry | hidden `$XDG_DATA_HOME/applications/chat.simplex.app-links.desktop` with `Exec= %u`, the path quoted only when the desktop-entry spec requires it, then `xdg-mime default` for each scheme; the bundled `chat.simplex.app.desktop` declares the MimeType for AppImage integration tools | first launch; rewritten if the AppImage moved; the default is set only when none is set or the current handler's entry is no longer installed | +| Flatpak | `scripts/flatpak/chat.simplex.simplex.desktop` | `MimeType=x-scheme-handler/simplexchat;x-scheme-handler/simplex;` (Exec already has `%U`) | install of a Flathub release whose pinned commit includes this file | + +The registration code is in `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt`. It runs once on a background thread after `initApp()`. The installation kind comes from `desktopInstallation(platform, appPath, env)`. An unpackaged run (no `jpackage.app-path`) registers nothing, and `APPIMAGE` counts only when the launcher runs from `APPDIR`, resolved through symlinks, because the AppImage runtime exports it to every child process. The AppImage entry is rewritten, followed by `update-desktop-database`, only when its content changed. An AppImage is not registered when its file or any directory above it belongs to a user other than this user or root, or has permission bits that let others write it, since the entry would run that file for every link; POSIX ACLs are not inspected. A path with a line break or `%` is not registered either, since GLib looks the program up before it expands `%%`. The path in `Exec` is quoted only when the desktop-entry spec requires it, because `xdg-open` 1.1.3 (Debian 12, Ubuntu 24.04) cannot run a quoted program in its generic mode, which Sway and other desktops it does not recognise use. + +## Receiving the link + +- **Closed, Windows and Linux:** the link is the only argument of `main`. `linkFromArgs` accepts it only when it is the single argument, starts with `simplexchat:` or `simplex:` and is at most 8192 bytes in UTF-8, enough for a one-time link with its post-quantum key (about 2 KB) and for a second such key. +- **Running, Windows and Linux:** the second process writes the link to an owner-only temp file in `dataDir` and renames it to `simplex.show`; if that fails, it writes an empty signal, which still raises the running app. The running app renames it to a private name, accepts at most 8192 bytes, deletes it, opens the link and shows its window. A signal present when the watcher starts is handled too, unless it predates the lock attempt by more than a 2 s tolerance for file time granularity; such a signal is left by an earlier session and is deleted instead, as are old temp files and a taken file left by a crash. On Windows the second process first calls `AllowSetForegroundWindow` so the window can come to the front, and logs a refusal. +- **Hung running app:** the existing "not responding" alert stays. "Start anyway" withdraws the signal and the new process opens the link itself; if the running app recovered and took the signal while the alert was up, the new process exits instead, so the link opens once. If renaming the signal fails, for example while another program holds it open, the running app retries once after 100 ms. +- **macOS:** the link arrives as an Apple Event, closed or running. `installOpenUriHandler` sets the handler on macOS only; the JDK holds an event that arrives during startup until then. +- **Bringing the window forward on Linux:** AWT's `toFront()` sends only `XRaiseWindow`, which Sway drops and focus stealing prevention refuses. `showWindow` makes the window visible at once and also sends `_NET_ACTIVE_WINDOW` (pager source) through JNA, after syncing AWT's connection so the map request comes first. On Sway the result follows `focus_on_window_activation`: the default `urgent` marks the window urgent, `smart` or `focus` focuses it. This was verified on headless Sway 1.9 with XWayland. + +## Badge page ending + +`appLinkSchemeRegistered()` decides the page flag when *Buy in browser* is tapped: + +| Result | Page URL | App side | +|---|---|---| +| registered | `#/tier?app=true` | page ends with *Return to SimpleX* and *Show code* | +| not registered | `#/tier?app=desktop` | Redeem code screen opens beside the browser (fallback) | + +Only `simplexchat:` counts here; `simplex:` is registered alongside it without being checked. By build, macOS is registered when packaged; Windows when the shell (`AssocQueryStringW`) names this exe as the program for `simplexchat:`; Flatpak always; AppImage and deb when `xdg-mime` reports their own entry as default. Android always reports registered. The button reads the flag once, so the URL and the fallback screen agree. + +## Security + +- The link is a bearer token. No log line on this path includes it. +- Any web page or program can send a link. The redeem screen from #7592 asks before redeeming and names the profile. +- The code arrives as a command-line argument. On Linux any local user can read it through `/proc//cmdline` unless `/proc` is mounted with `hidepid`; on a cold start the argument belongs to the app itself and stays readable until it exits, otherwise to the second process for about a second. On Windows only the same user and administrators can read it. This is an accepted risk; removing it needs the page to encrypt the code to a key the app passes in the URL fragment. +- The signal file holding the link is owner-only on POSIX and deleted as soon as it is read. It stays on disk only if the running app is hung. A later start that takes the lock deletes it without opening the link once it is more than 2 s older than the lock attempt; while the hung app still holds the lock, a later launch replaces the file and shows the alert again. +- Another program can claim the same scheme (on Windows, an app registered through `Capabilities` or picked in default-app settings wins; on Linux, the last `xdg-mime default` wins). That risk is accepted, and *Show code* on the page remains. + +## Automated verification (done) + +- `./gradlew :common:desktopTest`: all tests pass; this work adds `AppLinksTest`, `AppLinkSchemeTest`, `BadgePageUrlTest`, `WindowActivationTest` and nineteen `SingleInstanceTest` cases. + - `AppLinksTest`: accepts `simplexchat:` and `simplex:` in any case, including a one-time link with its post-quantum key; rejects `https:`, another scheme sharing the prefix, a path, empty, a link that is not the only argument, and a link over 8192 bytes, including a multi-byte one shorter than that in characters. + - `SingleInstanceTest`: link and empty signals round trip and are taken once; a later signal replaces an untaken one, and a taken file left by a crash is replaced; the file is owner-only (on POSIX); a link of exactly 8192 bytes and a one-time link with its post-quantum key are accepted; non-link content, an oversized link and a multi-byte link over the byte bound are dropped and still removed; the temp file is created beside the signal (skipped where the watch service polls, as on macOS); the startup sweep removes old temp files and a taken file but keeps a fresh temp file and the old lock and database files, and keeps a signal within the 2 s tolerance but drops an older one; the watcher takes a signal written before its watch, and successive signals arriving while it watches (skipped where the watch service polls, as on macOS), through a real `WatchService`, each once. + - `AppLinkSchemeTest`: Windows command quoting, the registry values, the deb counting as registered only when its own entry is the default, the ProgIds and the application listing, and registration with a stand-in shell (writing every value when the commands point at a missing program, leaving a full registration alone without notifying Windows, repairing a missing value or a missing application listing and notifying, keeping another installed program's or another copy's handler per-user or machine-wide, repairing this app's ProgId while another program holds the scheme, updating a command for this exe with other arguments, leaving a scheme alone when its handler cannot be read or its ProgId cannot be written, and reporting registered only when the shell names this exe, in any letter case); desktop-entry quoting that leaves a plain path bare, quotes every reserved character, keeps spaces inside the quotes and escapes `"`, `` ` ``, `$` and `\`; the full AppImage entry and its refusal of line breaks and `%`; `desktopInstallation` precedence, including Flatpak over a matching AppImage, inherited AppImage variables, a mount path that only shares a prefix, and a mount reached through a symlink; the AppImage ownership check for world-writable and group-writable files and directories, a world-writable ancestor, another owner, a root-owned file, and a missing file; AppImage registration writing the entry into a missing directory and setting both defaults, leaving unchanged defaults alone, setting the defaults again for an unchanged entry, setting only the default that points elsewhere, rewriting the entry of a moved AppImage, failing when another handler keeps the `simplexchat:` default, succeeding when it keeps only the `simplex:` one, and refusing a path the entry cannot run; `runProcess` output, discarded error output, an end of input and failure. + The ownership, AppImage registration and `runProcess` cases need POSIX permissions and `sh`, and the symlinked-mount case needs symlink rights, so they are skipped on Windows. + - `BadgePageUrlTest`: `app=true` when the link returns, `app=desktop` otherwise. + - `WindowActivationTest`: the `_NET_ACTIVE_WINDOW` client message targets the window with the pager source and `CurrentTime`, checked in native memory; it is sent to the root window with the window manager's event mask, and the display is closed even when sending fails (skipped where libX11 cannot load, as on Windows and macOS). +- `./gradlew :desktop:compileKotlinJvm` and `./gradlew :common:compileDebugKotlinAndroid`: succeed, no new warnings. +- The `make-deb-linux.sh` sed applied to the v7.0.3 deb's desktop file gives the intended lines, and `update-desktop-database` lists it for `x-scheme-handler/simplexchat`. +- The AppImage entry passes `desktop-file-validate` and reaches the MIME cache. + +## Manual verification (to do) + +Test with `simplexchat:/badge/code/` from a real purchase, or with the page mock (`apps/simplex-badge-service/web/mock/server.py`, open `#/tier?app=true` in a private window, settle with `/control/settle/`). + +### macOS (signed `.dmg`, installed to /Applications) +- [ ] `lsregister -dump | grep simplexchat` lists the installed app +- [ ] app closed: `open 'simplexchat:/badge/code/'` starts it and shows the redeem screen +- [ ] app running, window behind others: link raises it and shows the redeem screen +- [ ] app hidden in the tray: link shows the window +- [ ] dmg still mounted beside the installed copy: the installed copy opens +- [ ] Safari, Chrome, Firefox: *Return to SimpleX* prompts, then opens the app + +### Windows (`.msi` install) +- [ ] after first launch, `HKCU\Software\Classes\simplexchat\shell\open\command` is `"C:\Program Files\SimpleX\SimpleX.exe" "%1"` +- [ ] app closed: `start simplexchat:/badge/code/` starts it and shows the redeem screen +- [ ] app running: link reaches the running app, window comes to the front +- [ ] app hidden in the tray: link shows the window +- [ ] the argument arrives as one piece after the launcher restarts itself +- [ ] Edge, Chrome, Firefox: *Return to SimpleX* prompts, then opens the app +- [ ] after an MSI upgrade, the key still points at the installed exe +- [ ] SimpleX is offered under *Settings → Default apps → SIMPLEXCHAT*; picking another app there makes the page end with `app=desktop` after a restart +- [ ] fresh install, no choice made: the page ends with `app=true`, and `start simplexchat:/badge/code/` opens SimpleX +- [ ] with another app registered through `Capabilities` and no choice made: the page's ending matches what `start simplexchat:` actually opens (`app=true` only if SimpleX opens) + +### Linux +- [ ] deb on GNOME and KDE: `xdg-mime query default x-scheme-handler/simplexchat` gives `simplex-simplex.desktop` +- [ ] deb, app closed and running: `xdg-open 'simplexchat:/badge/code/'` shows the redeem screen +- [ ] AppImage: after first launch the hidden entry exists and is the default; moving the AppImage and relaunching updates it +- [ ] Flatpak: link opens the Flatpak app (needs the Flathub update) +- [ ] Firefox and Chrome from the distribution, snap Firefox on Ubuntu, Flatpak Firefox: *Return to SimpleX* opens the app +- [ ] GNOME on Wayland: window comes forward +- [ ] Sway: default config marks SimpleX urgent; with `focus_on_window_activation focus` the link focuses it +- [ ] deb and AppImage: `xdg-mime query default x-scheme-handler/simplex` names the app's entry + +### All platforms +- [ ] app locked with a passcode: the redeem screen appears after unlock +- [ ] no active profile: the link waits until a profile is active +- [ ] desktop connected to a mobile: the badge goes to the active remote profile +- [ ] a second link while one redeems is ignored +- [ ] no log file or console output contains the code +- [ ] a `simplex:` contact or one-time link from a browser, app closed and running: the connect dialog opens and asks before connecting +- [ ] unpackaged `./gradlew run`: page gets `app=desktop`, Redeem code screen opens beside the browser +- [ ] `./gradlew runDistributable` and the installed app keep each other's registration while both exes exist; delete the build output (or the HKCU keys) to give links back to the installed app +- [ ] two installed copies: the scheme stays with the first; after uninstalling it, the next start of the other copy takes the scheme + +## Known gaps + +The registration gaps below are also recorded as GAP-08 in `apps/multiplatform/product/gaps.md`. + +- A Flatpak always counts as registered, since the sandbox cannot query the host's default handler. If another installation holds the default, the page sends `app=true` and the link opens that installation; *Show code* still works. +- On Sway the window only becomes urgent unless the user sets `focus_on_window_activation smart` or `focus`. No client-side request can do more: Sway accepts activation tokens only for native Wayland windows. +- The Flathub release bump must pin a simplex-chat commit that includes the desktop file's MimeType line, and the Flathub wrapper must pass `"$@"` (done on its `update` branch). +- The HKCU key and the AppImage entry stay after uninstalling or deleting the app. On Windows, after an install into a folder other users can write to (such as a custom folder at the root of `C:\`), another user could place a program at the registered path; this accepted risk does not apply to the default Program Files install. +- On macOS, if LaunchServices starts a second copy of the app while one runs, that copy forwards no link: the URL arrives as an Apple Event the second copy never handles. +- jpackage's deb desktop file already has `Categories=Unknown`, which `desktop-file-validate` rejects, and a generic `Name=simplex`. The scheme still registers, and this work leaves both as they are. + +## Commits + +| Commit | Change | +|---|---| +| `tests: close the walk stream in withTempDir` | `Files.walk` closed in the existing `SingleInstanceTest` helper | +| `desktop: receive app links from args and macOS` | `AppLinks.kt`, `main(args)`, macOS handler | +| `desktop: forward an app link to the running app` | signal file payload in `SingleInstance.kt` | +| `desktop: initialize the desktop platform lazily` | `desktopPlatform` class initialization cycle | +| `tests: share the temp directory helper` | `withTempDir` moved to `TempDir.kt` for the scheme tests | +| `desktop: register simplexchat scheme in packages` | Info.plist, deb, AppImage and Flatpak desktop entries | +| `desktop: register simplexchat scheme at runtime` | `AppLinkScheme.desktop.kt` (Windows, AppImage, detection) | +| `ui: end the badge page with a link on desktop` | `appLinkSchemeRegistered()`, `badgePageUrl(linkReturns)`, fallback | +| `desktop: raise the window over other apps` | `isVisible` in `showWindow` and its skip of a disposed window during a crash restart, `_NET_ACTIVE_WINDOW` in `WindowActivation.kt`, `AllowSetForegroundWindow` on Windows | +| `docs: describe desktop deep links in the spec` | `navigation.md`, `architecture.md`, spec `README.md`, `impact.md`, `CODE.md` Document Map, `concepts.md`, `gaps.md` (GAP-08) | +| `docs: plan desktop deep links` | badge plan, plan | +| `docs: report on desktop deep links` | this report | +| `desktop: open simplex connection links` | `simplex:` accepted and registered beside `simplexchat:`, one 8192-byte bound, desktop link names without `App` | +| `docs: describe simplex links on desktop` | spec, `gaps.md`, plan and this report | +| `desktop: keep a link from opening twice` | `startDespiteRunningInstance()`: "Start anyway" withdraws the signal, and exits if the running app already took it | +| `desktop: retry taking a signal held open` | `takeSignal` retries a failed rename once | +| `desktop: keep the user's AppImage link handler` | the AppImage takes a scheme only when no handler is set or the current handler's entry is no longer installed | +| `desktop: register Windows links through Capabilities` | scheme keys and `SimpleX.` ProgIds, `Capabilities` with `URLAssociations` and `RegisteredApplications`; every value compared and repaired; another installed program's or copy's handler kept, per-user or machine-wide; registered when `AssocQueryStringW` names this exe; `SHChangeNotify` only after a change | +| `desktop: add %u to the deb entry only once` | the deb's `Exec` sed skips a line that already has a field code | +| `docs: describe the review fixes` | spec, `gaps.md`, plan and this report | diff --git a/plans/2026-09-28-desktop-deep-links.md b/plans/2026-09-28-desktop-deep-links.md new file mode 100644 index 0000000000..1097cab987 --- /dev/null +++ b/plans/2026-09-28-desktop-deep-links.md @@ -0,0 +1,145 @@ +# Desktop deep links: `simplexchat:` on Linux, Windows, macOS + +## Context + +The badge page (`badges.simplex.chat`) ends a purchase opened from the app with *Return to SimpleX*, which navigates to `simplexchat:/badge/code/`. Android and iOS handle it since #7592 (`isAppLink` → `openAppLink` → `openBadgeLink` → `BadgesRedeemLinkView`, which confirms before redeeming). Desktop registers no scheme, so it opens the page with `app=desktop` and relies on the user pasting the code (D1). Goal: every packaged desktop build receives the link, cold or running, and sends `app=true` only when a link is expected to reach this installation. D1 stays as the fallback. + +The decisions were to build on #7592, forward to a running instance through the existing signal file with a payload, register `simplexchat:` only (`simplex:` connection links were added afterwards, see the last section), and self-register at runtime on Windows (HKCU) and AppImage. + +## Research summary + +- **macOS**: `CFBundleURLTypes` in Info.plist; LaunchServices registers on copy to /Applications. URL arrives only as an Apple Event through `Desktop.setOpenURIHandler`; JDK 17 queues it until the handler is set (`_OpenURIDispatcher`), so cold start is safe for AWT/Compose. The running app receives it in-process, so no second process starts. +- **Windows**: `HKCU\Software\Classes\simplexchat` with `URL Protocol`, `shell\open\command = "" "%1"`. Compose 1.8.2 exposes no `--resource-dir`, so installer registration needs a WiX fork; runtime HKCU is what Signal, Element, Telegram do. URL arrives as argv of a new process. `jpackage.app-path` is set by the jpackage launcher (JDK 16+). +- **Linux**: `.desktop` with `MimeType=x-scheme-handler/simplexchat;` and `Exec=... %u`, visible once `mimeinfo.cache` is rebuilt. The v7.0.3 deb ships `/opt/simplex/lib/simplex-simplex.desktop` with `Exec=/opt/simplex/bin/simplex` and empty `MimeType=`; its postinst runs `xdg-desktop-menu install`, which runs `update-desktop-database`. AppImage has no install step, so it self-registers in `~/.local/share/applications`. Flatpak exports the `.desktop` MimeType (file is in `scripts/flatpak/`). +- **Browsers**: all prompt before launching; Chrome needs a user gesture (the page's button provides it). No reliable way for the page to know the app opened, so *Show code* stays. +- **Security**: link is a bearer token in argv, which on Linux other local users can read through `/proc` (for the whole session after a cold start); this risk is accepted. The lane already confirms before redeeming. Nothing may log the URI. + +## Flow + +``` +browser ─ simplexchat:/badge/code/X + macOS: Apple Event → setOpenURIHandler → openDesktopLink + Windows/Linux: new process main(args) → linkFromArgs + lock acquired → chatModel.appOpenUrl (cold start), then the watcher starts + lock taken → simplex.show payload → primary watcher → openDesktopLink +openDesktopLink: chatModel.appOpenUrl = remoteHostId() to uri; showWindow() +App.kt LaunchedEffect (existing) → connectIfOpenedViaUri → openAppLink (lane) +``` + +## Changes + +1. **`common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt`** (new, beside `SingleInstance.kt`) + - `isAcceptedLink(uri)`: `isAppLink` (`ChatListView.kt`) within `MAX_LINK_BYTES` UTF-8 bytes. + - `linkFromArgs(args)`: the only argument, when it is an accepted link; anything else is ignored. + - `openDesktopLink(uri)`: on the EDT, `chatModel.appOpenUrl.value = chatModel.remoteHostId() to uri`, then `showWindow()` (`DesktopApp.kt:258`), so a failure to show the window cannot lose the link. `remoteHostId()` matches the redeem screen (`BadgesRedeemCodeView.kt:98`). + - `installOpenUriHandler()`: called on macOS only (so Windows and Linux do not start AWT early); sets a handler that forwards accepted links to `openDesktopLink`. + +2. **`desktop/.../Main.kt`**: `main(args)`. Compute the link and pass it to `acquireSingleInstance(link)`; when this process runs, put it into `chatModel.appOpenUrl` (`null` host), then start the watcher if this process holds the lock, so a link the watcher forwards later is not overwritten. A short-lived second process never touches `chatModel`. After `initApp()`: start registration (step 4) and `installOpenUriHandler()`. Keep everything inside the existing try. + +3. **`SingleInstance.kt`**: the signal carries an optional payload. + - Second process: `Files.createTempFile(dataDir, "simplex.show", ".tmp")` (owner-only on POSIX), write the link or nothing, then `Files.move(tmp, showPath, ATOMIC_MOVE)`, which replaces an untaken signal on Linux and Windows. If that fails, fall back to an empty signal, which still brings the running instance forward. The rename arrives as `ENTRY_CREATE` on inotify and Windows. Existing 1 s wait and hung-primary alert stay; on "start anyway" the payload file is deleted and `main` handles the link itself. + - Primary watcher: rename `simplex.show` to a private temp name, so a newer signal renamed over it is not deleted unread, then accept at most `MAX_LINK_BYTES` bytes, delete it, and post `openDesktopLink(link)` or `showWindow()`. + - Primary start deletes a taken file left by a crash, and any `simplex.show*` file older than its lock attempt minus a 2 s tolerance for file time granularity: such a signal or temp file was left by an earlier session, while a fresh temp file is a signal being written. `main` then starts the watcher, which registers the watch and handles a signal already present, which raised no event. + - Windows: call `AllowSetForegroundWindow(ASFW_ANY)` before signalling so the primary can come forward (best effort). jna-platform 5.14 does not bind it, so it is looked up through `NativeLibrary`. + - Take the directory as a parameter internally so tests can use a temp dir. + - No log line includes the link. + +4. **`common/src/desktopMain/.../platform/AppLinkScheme.desktop.kt`** (new): `registerLinkSchemes()` runs once on a background thread and sets a `@Volatile` result read by the UI. `unixDataHome` is exposed from `Platform.desktop.kt` and `appLinkScheme` from `ChatListView.kt` for reuse. + - macOS: registered when `jpackage.app-path` is set (packaged bundle). + - Windows: when `jpackage.app-path` is set, read the HKCU command via `Advapi32Util`. If it differs from `"" "%1"`, write the default value `URL:SimpleX Chat`, `URL Protocol`, `DefaultIcon`, and `shell\open\command`. Registered if the key now matches. + - Linux: + - `container=flatpak` set (the check `AppUpdater` uses) → registered. + - AppImage, when `APPIMAGE` and `APPDIR` are set and the launcher runs from `APPDIR`, resolved through symlinks (the runtime exports `APPIMAGE` to every child process) → write `$XDG_DATA_HOME/applications/chat.simplex.app-links.desktop`: `NoDisplay=true`, `Exec= %u` with the path quoted only when the desktop-entry spec requires it (`xdg-open` 1.1.3 cannot run a quoted program), and the MimeType line; a path with a line break or `%` is refused (GLib looks the program up before it expands `%%`), and so is an AppImage whose file or any directory above it another user could replace (owned by anyone but the user or root, writable by others, or group-writable by a group other than the user's own; POSIX ACLs are not inspected). Rewrite it and run `update-desktop-database` only when the content differs, then run `xdg-mime default` unless the entry is already the default. + - Otherwise (deb): registered when `xdg-mime query default x-scheme-handler/simplexchat` names the deb's own `simplex-simplex.desktop`. Processes are run without a shell and with a timeout. + - The decision is the function `desktopInstallation(platform, appPath, env)`, so it is unit tested. + - `desktopPlatform` becomes lazy in its own commit: the new tests reference a `DesktopPlatform` constant first, and the eager value was then null through a class initialization cycle. + - Unpackaged runs register nothing and report false. + +5. **Common UI** + - `expect fun appLinkSchemeRegistered(): Boolean` in `platform/AppCommon.kt`, with Android actual `true` and desktop actual returning step 4's result. + - `BadgeStore.kt`: `badgePageUrl(linkReturns)` returns `app=true` or `app=desktop`; the button reads the flag once and passes it, so the URL and the fallback screen agree. + - `BadgesSupportSimplexView.kt` `BuyInBrowserButton`: open the Redeem code screen beside the browser only when not registered (replaces `appPlatform.isDesktop`), and update its comment. + +6. **Packaging** + - `desktop/build.gradle.kts`: add `CFBundleURLTypes` (`CFBundleURLName` `chat.simplex.app`, scheme `simplexchat`) to the existing `extraKeysRawXml`. + - `scripts/desktop/make-deb-linux.sh`: after `dpkg-deb -R`, sed `extracted/opt/*imple*/lib/*.desktop` so `Exec` ends in ` %u` and `MimeType=x-scheme-handler/simplexchat;`, and fail if the MimeType line is missing. This must run before the timestamp `touch`. + - `desktop/src/jvmMain/resources/distribute/SimpleX.desktop` (AppImage only): add the MimeType line and `%u` to Exec. In `scripts/desktop/make-appimage-linux.sh`, make the Exec sed replace only the program, so `%u` is kept. + - `scripts/flatpak/chat.simplex.simplex.desktop`: add the MimeType line. Confirm the Flathub manifest takes this file. + +7. **Window activation** (`WindowActivation.kt`, `DesktopApp.kt showWindow`): `showWindow` skips a disposed window (during a crash restart), sets `isVisible` immediately, and on Linux also sends `_NET_ACTIVE_WINDOW` (pager source) through JNA after syncing AWT's connection, because AWT's `toFront()` sends only `XRaiseWindow`, which Sway drops. + +8. **Docs** + - `apps/multiplatform/spec/client/navigation.md`: replace "Desktop registers no URL scheme" with the desktop paths. + - `apps/multiplatform/spec/architecture.md` and `spec/README.md`: the desktop `main()` steps, `showWindow()` and the line references they shift. + - `spec/impact.md` and the `CODE.md` Document Map: rows for the new desktop files, with `Main.kt` and `SingleInstance.kt` mapped to `navigation.md` as well; the Source Files tables in `navigation.md` and `architecture.md`; `product/concepts.md`: a row for `AppLinks.kt`; `product/gaps.md`: GAP-08 for the known registration gaps, marked `[GAP]` in `navigation.md`. + - `plans/2026-09-24-badge-buy-in-browser.md`: mark the desktop deep links section as done. + +## Commits + +1. `tests: close the walk stream in withTempDir` +2. `desktop: receive app links from args and macOS` +3. `desktop: forward an app link to the running app` +4. `desktop: initialize the desktop platform lazily` +5. `tests: share the temp directory helper` +6. `desktop: register simplexchat scheme in packages` +7. `desktop: register simplexchat scheme at runtime` +8. `ui: end the badge page with a link on desktop` +9. `desktop: raise the window over other apps` +10. `docs: describe desktop deep links in the spec` +11. `docs: plan desktop deep links` +12. `docs: report on desktop deep links` +13. `desktop: open simplex connection links` +14. `docs: describe simplex links on desktop` +15. `desktop: keep a link from opening twice` +16. `desktop: retry taking a signal held open` +17. `desktop: keep the user's AppImage link handler` +18. `desktop: register Windows links through Capabilities` +19. `desktop: add %u to the deb entry only once` +20. `docs: describe the review fixes` + +## Verification + +- **Unit tests** (`common/src/desktopTest`, beside `SingleInstanceTest.kt`): + - `linkFromArgs`: accepts `simplexchat:` in any case; rejects `https:`, empty args, a link that is not the only argument, and a link over the bound in bytes, including a multi-byte one within it in characters. + - Signal payload: link and empty round trip, taken once, replace of an untaken signal and of a taken file left by a crash, owner-only file on POSIX, a link of exactly the byte bound, rejected content including a multi-byte link over the bound, a temp file created beside the signal, a sweep that removes old temp files and a taken file but keeps a fresh temp file and the old lock and database files, keeps a signal within the 2 s tolerance and drops an older one, a watcher that takes a signal written before its watch, and successive signals arriving while it watches, each once, through a real `WatchService`. + - The Windows command string, the registry values and registration with a stand-in registry, the deb's registered check, the AppImage desktop entry for a path with spaces, Exec quoting that leaves a plain path bare and quotes and escapes reserved characters, its refusal of line breaks and `%`, `runProcess` output, discarded error output, an end of input and failure, and `desktopInstallation` precedence including Flatpak over AppImage, inherited AppImage variables, a mount path that only shares a prefix, and a mount reached through a symlink; the AppImage ownership check for world-writable and group-writable files and directories, a world-writable ancestor, another owner, a root-owned file, and a missing file; AppImage registration with a stand-in for `xdg-mime`: a new entry in a missing directory, an unchanged default, an unchanged entry that is no longer the default, a moved AppImage, a default that does not change, and a path the entry cannot run. + - `badgePageUrl` (in `common/src/commonTest`): `app=true` when the link returns, `app=desktop` otherwise. + - `activationEvent` and `sendActivation`: a `_NET_ACTIVE_WINDOW` client message for the window, with the pager source and `CurrentTime`, checked in native memory, sent to the root window through a stand-in for libX11 that also checks the display is closed. +- **Commands**: `./gradlew :common:desktopTest`, `./gradlew :desktop:compileKotlinJvm` and `./gradlew :common:compileDebugKotlinAndroid`, with no new warnings. +- **Manual matrix** on packaged builds. States: app closed, running, hidden in tray, locked with passcode, no active profile. + - Launch commands: `open 'simplexchat:/badge/code/'`, `start simplexchat:/badge/code/`, `xdg-open ...`. + - End to end through the page mock (`apps/simplex-badge-service/web/mock/server.py`, `#/tier?app=true`, settle via `/control/settle/`) in Chrome, Firefox and Safari. + - Linux: deb on GNOME and KDE, snap Firefox, Flatpak, AppImage. + - Check `lsregister -dump | grep simplexchat` on macOS and the HKCU key on Windows. + - Confirm that no log contains the code. +- **Unpackaged `./gradlew run`**: reports not registered and keeps D1. + +## Risks to confirm on hardware + +- How the Windows launcher re-quotes argv when it restarts itself. +- macOS cold start with Compose 1.8.2. +- Snap Firefox on Ubuntu opening custom schemes. +- GNOME Wayland raising the window (best effort). +- Stale HKCU keys and AppImage entries stay after uninstall or deletion; the plan accepts that. +- The deb's existing `Categories=Unknown`, which `desktop-file-validate` rejects, is out of scope and left as is. + +## `simplex:` connection links + +Android already opens `simplex:` links through `connectIfOpenedViaUri`, which sends them to `planAndConnect` (it asks before connecting). Desktop now receives them by the same paths as app links. + +- `isAcceptedLink` accepts `isAppLink` or `isConnectionLink` (`ChatListView.kt`). One bound covers both: `MAX_LINK_BYTES` is 8192, as a one-time link with its post-quantum key is about 2 KB and a second such key would bring it near 4 KB (the sntrup761 key alone is 1544 characters in base64url). The desktop names lose the `App` part: `linkFromArgs`, `openDesktopLink`, `registerLinkSchemes`. +- Every registration declares both schemes: the Info.plist, the deb, AppImage and Flatpak desktop entries, an HKCU key per scheme on Windows, and `xdg-mime default` per MIME type for an AppImage. +- `appLinkSchemeRegistered()` still reports `simplexchat:` only, as it decides how the badge page ends; `simplex:` is registered without being checked. +- Tests: a short link, a one-time link with its key, and the scheme in another case are accepted; another scheme sharing the prefix and a path are not; a forwarded one-time link round trips through the signal file; Windows and AppImage registration write both schemes, and only `simplexchat:` decides the result. + +## Review fixes + +- "Start anyway" after the not-responding alert withdraws the signal with `deleteIfExists`; when the running instance has already taken it, the second process exits, so the link opens once (`startDespiteRunningInstance`, extracted from `acquireSingleInstance` so it is tested). +- `takeSignal` retries a failed rename once after 100 ms, as another program (an antivirus scanner on Windows) can hold the file open for a moment. The move is a parameter, so tests make it fail. +- Windows registration compares and repairs every value, not only the command; reads a value of another type as missing instead of letting JNA's `RuntimeException` end the registration thread; and reports not registered when `UserChoice` names another ProgId (replaced later by asking the shell, below). +- Registration takes a scheme only when no handler is set or the current one is no longer installed: on Windows when the command's program file is missing, for an AppImage when the current default's desktop entry is in no applications directory. Another installation keeps it, and the badge page falls back to `app=desktop`. +- The deb's `Exec` sed adds `%u` only to a line without a field code. +- Windows: a test on Windows 11 showed that a scheme key alone loses to any app registered through `Capabilities`: links went to that app without asking, and default-app settings did not offer SimpleX. Each scheme now also gets a `SimpleX.` ProgId named in `HKCU\Software\chat.simplex.app\Capabilities\URLAssociations`, and the application is listed in `HKCU\Software\RegisteredApplications` last. `UserChoice` was compared with that ProgId until the shell check below replaced it. The scheme key stays, as it declares the URL scheme to Windows. +- Not changed: stale-signal detection still compares file times with the local clock (network file systems with clock skew above 2 s are an accepted edge case). +- A second review of the Windows change: Windows 11 can keep the user's pick in `UserChoiceLatest` and stop updating `UserChoice`, and with several capable apps and no pick Windows chooses or asks. So the registered check no longer reads either key: it asks the shell which program opens `simplexchat:` links (`AssocQueryStringW` with `ASSOCSTR_EXECUTABLE`) and compares it with this exe. This app's own ProgId is rewritten even while another program holds the scheme, so a pick in settings keeps working after a reinstall elsewhere; the "another program" check also reads `HKLM\Software\Classes`, which a per-user key would otherwise hide; the application is listed only when at least one scheme is this app's; and `SHChangeNotify(SHCNE_ASSOCCHANGED)` follows any write. Both functions are called through `NativeLibrary`, as jna-platform 5.14 binds neither. +- A third review: `SHChangeNotify` also clears Explorer's icon cache, so it is sent only after a write. Another installed copy's ProgId (its command names an existing exe other than this one) is kept like any other handler, so two installed copies do not take the user's pick from each other at every start. A command naming this exe with other arguments or letter case counts as this app's own and is updated. A ProgId that cannot be written keeps its scheme unregistered, so `URLAssociations` never names a missing ProgId. diff --git a/plans/2026-10-09-badge-topups-subscriptions.md b/plans/2026-10-09-badge-topups-subscriptions.md new file mode 100644 index 0000000000..f43f5d50aa --- /dev/null +++ b/plans/2026-10-09-badge-topups-subscriptions.md @@ -0,0 +1,447 @@ +# Badge top-ups and store subscriptions — design space + +Branch `badge-in-app-checkout` builds a store lane for one-time badge purchases. It is not released. Two things are not built: + +- **Top-up:** buying more months while a badge is shown. +- **Subscriptions:** the stores' monthly and annual plans. + +This document compares two top-up designs, sets out how top-up and subscriptions depend on each other, lists the work and external waits of each step, and lists what the product lead must decide. It is a design space, not an implementation plan. + +## Summary + +**Does one need the other?** + +- Subscriptions do not need top-up, if Subscribe is offered only when no badge is shown and tier changes use a tier-changing grant. +- Top-up A needs nothing from subscriptions. +- Top-up B shares machinery with same-ledger subscriptions: lapse-before-grant and the late-landing problem. + +**Smallest correct top-up: design A.** Store Buy opens in a short window before the shown badge ends. The payment is credited at once as a new purchase that becomes the shown badge. Nothing changes in the service or the ledger. The buyer gives up the overlap, at most the window. + +**Subscriptions on top cost:** + +- service: a charges table, a ledger reader that understands charges, renewal discovery, a database lock, a tier-changing grant, and a verify path for Play subscriptions; +- client core: asking the service at zero months, keeping the badge on until it answers, and the store's expiry on the wire; +- apps: sale, restore, manage and cancel screens; +- external: store subscription setup, notifications, Pub/Sub, quotas, translations. + +**Late-presented renewals.** A grant added before unused months are written off loses the paid month. Writing off first fixes that, but coverage then restarts at presentation, 0–7 days late by default. Dating the grant to the store's billing period removes the drift. + +**Recommendations** (judgement): A for top-up, if subscriptions are far off; same-ledger subscriptions over A-style ones. + +**Decisions for the lead:** review test purchases; A, B, both or neither; A's window and downgrades; subscribing while a badge is shown; Family Sharing; refund clawback in the first release; Play App Signing custody. + +**Top risks:** the store product type; Play's owned-product rule and product identity; refunds while a payment is held; synchronised renewal requests. + +**Biggest open engineering question:** same-ledger subscriptions need a design for charges that land after the badge came off. The only worked fix is broad. + +## Reading notes + +**Labels.** + +- **Fact:** read in code at the place cited, or traced through it. +- **Model:** stated in the draft model, cited by section or line. +- **Judgement:** a conclusion drawn here. +- **Inference:** a consequence of cited facts, not run. +- **Unverified:** Apple or Google behaviour, and intent read from code comments. + +**Sources.** + +- Code is `badge-in-app-checkout` unless marked "vb", the unmerged `f/badge-store-verifiers`. "mq" is simplexmq at `e24d537`. +- The service is under `apps/simplex-badge-service/src/BadgeService/`. Its deployed backend is Postgres, so its `Migrations.hs` is `Store/Postgres/Migrations.hs`. +- Core is under `src/Simplex/Chat/`: `Library/Commands.hs`, `Badges/Ledger.hs`, `Store/Badges.hs`. `Chat.hs` is `src/Simplex/Chat.hs`. +- `M20260915_user_badges.hs` lines are the SQLite variant; the Postgres one is a line earlier. +- `badges-rpc.md` and `badges-overview.md` are under `docs/protocol/`. +- Swift files are under `apps/ios/Shared/`, Kotlin ones under `apps/multiplatform/`. `PlayStore.kt` is the `google` flavour's. +- **Model** is the draft `plans/2026-07-30-supporter-badges-v3-ux.md`. + +**Terms.** + +- **Shown badge:** the purchase in `users.shown_badge_id`. +- **Retirement:** taking the shown badge off at paid-through. +- **Held payment:** a store payment reaching a profile with a shown badge. It waits until retirement. +- **Hand-over:** the app passing a store receipt to core (`APIPurchaseBadge`). A **sweep** hands over every unfinished store purchase. +- **Tip:** a purchase's last ledger entry. **Run:** the months since the ledger's anchor. **Coverage** ends at paid-through. +- **Re-bind:** moving a purchase to new keys after the old keys are lost. +- **Step 1** is the one-time lane's release, which both features build on. + +## How it works today + +**Only the newest purchase is managed** (fact). + +- The badge worker and badge state read the newest purchase (`Store/Badges.hs:398-411`; `Commands.hs:5549`, `:5634`). +- Requests need a shown credential (`Commands.hs:5687-5691`). +- Retirement acts only on a shown newest purchase (`Commands.hs:5827-5835`). + +**While a badge is shown, nothing creates a purchase** (fact). Four guards refuse: + +- invoice creation (`Commands.hs:3587`); +- code redemption (`Commands.hs:5326`); +- store-receipt selection (`Store/Badges.hs:227`); +- store-receipt attempt (`Commands.hs:5529`). + +**When a held payment is credited** (fact unless marked). + +- Only retirement clears the shown badge. +- On iOS, retirement waits for a chat run; background refresh counts. +- On Kotlin, chat is never suspended (`Chat.hs:211`), so retirement happens at paid-through (`Commands.hs:5699-5707`). +- Credit then waits for chat start (`Commands.hs:269`), iOS activation (`:619`) or a hand-over (`:5298`). +- At start and activation, retirement runs asynchronously after the held-receipt check, so credit likely slips to a later trigger (inference). + +**A held payment while it waits.** + +- Its store transaction stays unfinished (fact, `BadgeStore.swift:240-261`). +- On Play it is acknowledged at hand-over but not consumed (fact, `BadgeStore.kt:281-283`), so Play's 3-day auto-refund does not apply. +- Play verification is online; a canceled `purchaseState` is refused (fact, vb `Google.hs:122-128`). Whether a refund sets that state is unverified. +- Apple verification is offline, over the stored transaction (fact, vb `Apple.hs:39-49`; `Store/Badges.hs:136`). A refund during the hold is credited unless the app re-hands a fresh transaction. +- Its product is mapped only at credit (fact, `Service.hs:446-448`; `Catalog.hs:148-154`). An unmapped product is retried daily and never finished (`Commands.hs:5507-5511`, `:5534-5541`). +- Another device on the same Play account sweeps unconsumed purchases (fact, `BadgeStore.kt:353-361`; `PlayStore.kt:103-116`). It records the purchase under its own profile and can credit it first (`Store/Badges.hs:144-148`). The buyer then gets `receipt_used` (`Service.hs:479`). Whether Play lists the purchase there is unverified. +- Whether Apple delivers another device's unfinished consumable is unverified. + +**The ledger** (fact, `Ledger.hs:93-101`). + +- `grantEntry` adds to the balance and keeps the anchor and tier. +- It restarts the run only when the balance is zero. + +**A grant must follow a lapse** (fact, traced). + +- A 2-month code is redeemed on 2 Oct, leaving 1 month unissued from 2 Nov. The app is away until 15 Feb. +- A bare +1 grant gives 2 months from 2 Nov. The next issue writes off min(2, 3) = 2, so the new month is lost. +- Lapsing first writes off 1, and the grant restarts the run on 15 Feb. + +**The service never grants onto an existing purchase** (fact). + +- A purchase starts from an empty ledger (`Service.hs:447-463`, `:489`). +- A receipt under a known key fails on `UNIQUE(purchase_key)` (`M20260915_user_badges.hs:92`) and is answered `internal`. The Play purchase is already acknowledged, so it is paid and never credited. +- Every refusal is answered before any database write (`Service.hs:420-463`). + +**Other facts.** + +- Store one-time months are issued at credit (`Catalog.hs:150-153`; `Service.hs:489-491`). An issued credential stays valid after a refund (model §3, O6). +- A Play quantity other than 1 is answered `internal` (`Service.hs:431-432`, `:471`) and retried forever (`Commands.hs:5732`). It is paid and never credited. +- With vb, Sandbox and licence-tester purchases are refused as `receipt_invalid` (`Service.hs:433`). +- Without vb, a well-formed receipt not credited to the presenting key is answered `provider_not_configured` (`Service.hs:424-427`). A malformed one is refused first, except a Play token failing the grammar check, which is retried as `provider_unavailable` (`StoreReceipts.hs:71-80`). +- vb will not start its dev mock beside a real verifier (vb `Config.hs:231-232`). + +## Top-up A: a new purchase from inside a window + +**What it is** (judgement; recommended). + +- Store Buy is allowed in a short window before the shown badge's paid-through, for example its last 7 days, until retirement. +- The payment is credited at once as a new purchase under new keys. It becomes the shown badge and is presented at once, as every credit is today (`Commands.hs:5865`, `:5515-5520`) and as the model's MVP says (model 2.5, line 486). +- Today Buy works only after retirement (fact, `Commands.hs:3587`; `Store/Badges.hs:227`). A's gain: the buyer pays before the badge ends, and the badge never goes off. + +**Why credit at hand-over, not at retirement** (judgement). + +- At hand-over, the new month starts at credit (`Service.hs:489`). The buyer loses the overlap, bounded by the window. +- At retirement, no days are lost, but every payment is held with the exposures above. +- Contacts would also see the badge removed and re-added. +- A warm iOS background refresh (`BGManager.swift:129`) and a backgrounded Kotlin app need new trigger plumbing to credit at retirement. + +**Changes.** + +- The invoice and receipt guards admit a purchase inside the window (`Commands.hs:3587`, `:5529`). +- The selection guard is SQL on `shown_badge_id IS NULL` (`Store/Badges.hs:227`), but paid-through is computed in Haskell, so that query is restructured. +- The query should filter by window, and the worker should wake when the window opens (judgement). Otherwise each refused attempt shows the buyer a `BIFUnexpected` error until a daily retry succeeds (`Commands.hs:5537-5541`, `:5725`, `:5625-5628`). +- The purchase is credited as its own tier (`Service.hs:447-449`), so no tier rule is needed for correctness. Downgrades are decision 3. +- The guards live in client core, so they bind honest clients only. +- The badge screens on both platforms rework their state. A shown badge replaces the purchase flow on iOS (`BadgesView.swift:26-31`) and closes purchase screens on Kotlin (`BadgesView.kt:28-32`, `:51-57`). + +**Recommended, cosmetic: tolerate a credit landing during retirement** (judgement). + +- Credit takes the badge lock; retirement takes the profile lock (`Commands.hs:5504`, `:5830`). +- Retirement reads the purchase once (`:5549`), then clears the profile badge unguarded (`:5833`). +- A credit committing in between makes contacts see the badge removed, then re-added on the next pass (`:5824`, `:5869`) (inference). +- The stale pass also raises "support ended" for the old purchase (`:5567`, `:5573`, `:5592`). The app may keep it until its next reload. +- Fix: clear the profile badge conditionally in retirement's transaction, and derive the alert from the shown purchase. + +**Optional** (judgement). + +- A prepaid-ending alert three days before paid-through. Without it the window is found only on the badge screen, because the chat-list pitch is hidden while a badge is shown (`BadgesView.swift:67-69`; `OnboardingCards.kt:66-67`). The apps render only two alert kinds today (`ChatListView.swift:389`, `:393`), so it is new UI. +- Deferring presentation until the old credential expires, as renewals do (`Commands.hs:5822-5824`). It keeps the request and the profile update on different days (objective 3, `badges-overview.md:205`). It must skip downgrades, and delays a higher tier's perks by up to 8 days. + +**Limits.** + +- The apps allow one pending purchase per profile (`BadgeStore.swift:120-135`, `BadgeStore.kt:167-183`). +- A is store-only. Codes still wait for retirement, so foss, desktop and link-out buyers get no top-up. +- A sold code expires 365 days after sale (`Orders.hs:21-22`, `:112`; `Service.hs:413`). A code bought d days into a 12-month badge stays redeemable on that profile for about d days after retirement. It can be redeemed on another profile at any time. +- Admitting codes in the window would fix both, at the cost of the code-screen rework (`BadgesRedeemCodeView.swift:288-310`, `BadgesRedeemCodeView.kt:369-377`). + +**What it links.** No payments by key. Timing links them: the new purchase starts near the old paid-through. + +## Top-up B: credit onto the shown badge + +**What it is** (judgement). The payment's months extend the shown badge's ledger, under its key. The badge is never retired and re-added. + +**Service.** + +- The request carries a store payment and the client's last ledger entry, signed by the purchase key. +- `upgradeBadgeSubscription` already has that shape (`Badges/Service.hs:94-98`; `badges-rpc.md:44`), less its `badgeRequest` field, which must be dropped (`badges-rpc.md:30`). It is unimplemented (`Service.hs:341-348`). +- The service lapses the tip, then grants, then issues if due. Lapsing first is required (see the grant trace above). +- Today's issue path writes only if the tip has not moved, but replies normally either way (`Service.hs:519-532`). B must answer a skipped write as an error. Otherwise the client marks the payment credited with nothing credited. +- Payments must be looked up through the ledger. Today's lookup joins only `badge_purchases.payment_id` (`Store.hs:204-218`), so a top-up re-presented by another device would get `internal` forever (`Service.hs:457-461`). +- A SKU of another tier is refused before any write. `grantEntry` keeps the ledger's tier, so crediting it would grant months at the wrong tier: legend months for a supporter payment, or the reverse. +- B is store-only. Admitting codes would also need the code lookup moved to the ledger, or a retried multi-use code would grant twice (`Store.hs:185-202`). + +**Client.** + +- The invoice and receipt guards admit a top-up in the window. The code guard stays: a code redeemed while a badge is shown would become newest and strand the stacked months (`Store/Badges.hs:303`, `:398-411`). +- The receipt row is marked as a top-up of the shown purchase at Buy, and signs with that purchase's keys. +- The row can fall back to A until its first request is sent, not after (`Service.hs:474-482`). +- A top-up during coverage usually issues nothing (`Ledger.hs:106`), but `requestBadgeStash` rejects a reply without a credential (`Commands.hs:5344-5346`). That path changes. +- A credited row with no purchase reads as refused today (`Store/Badges.hs:185-187`, `:289`). A credited mark fixes that. +- Retirement must re-read the tip in its own transaction; today it uses a stale one (`Commands.hs:5552`, `:5556`). + +**Late landing.** + +- Suppose the target retires and another purchase becomes newest before the credit lands. +- Re-showing the target strands the newer purchase and locks out later Buys and codes (`Store/Badges.hs:446-452`). Not re-showing it strands the new months. +- Fix one: defer retirement while a sent request's outcome is unknown. It has no bound while the device is offline. +- Fix two: manage every purchase with unissued months or an active store charge. That is broad: newest-only is assumed by `getUserBadgePurchase`, the single `BadgeState` (`Badges/Types.hs:270-284`), alert derivation (`Commands.hs:5590-5612`), retirement and both apps. + +**Costs.** + +- Stacked months are lost with the chat database. Re-bind cannot recover a finished one-time purchase if the store does not re-deliver it (unverified). +- A topped-up month lapses if the app does not run during it. Today every store month is issued at credit. +- Unissued months keep their value after a refund, unless a stacking cap or refund handling exists. +- It links payments by key, including a store payment to a code-funded badge's key. That goes beyond the exceptions at `badges-rpc.md:13` and against `badges-overview.md:206`. + +## How top-up and subscriptions depend on each other + +**Top-up A needs nothing from subscriptions** (judgement). + +- Exception: if Apple requires a month of access to be a non-renewing subscription (unverified), restore work moves into step 1 (risk 1). + +**Subscriptions need nothing from top-up, under conditions** (judgement). + +- The first charge: offer Subscribe only when no badge is shown, as one-time Buy works today. Then existing holders wait for retirement to subscribe. +- Tier changes always reach a profile showing that subscription's badge, and Apple's store UI can change tier without the app (unverified). Same-ledger subscriptions need a tier-changing grant anyway (Subscriptions), which handles this. +- Subscribing while a badge is shown would reuse A's guards and screens. + +**Two ways to credit renewals.** + +- **Same-ledger:** each renewal credits the subscription's own purchase. +- **A-style:** each renewal is credited as a new purchase under fresh keys. + +**What same-ledger shares with B** (judgement). + +- Lapse-before-grant on an existing ledger. +- Keeping the badge on until the service answers whether a charge landed. +- The late-landing problem: a subscription revived after grace (Apple billing retry, Play account hold, resubscription; unverified) can land after another purchase became newest. B's broad fix two solves it. +- If the app also hands renewals over: B's client half, its ledger payment lookup, and the tip re-read at retirement. +- Not shared: B's cross-tier refusal, its top-up rows, and stacking. + +**What A-style costs** (judgement; not recommended). + +- On iOS, a renewal handed over at launch races retirement, so the badge can come off and on (inference). Avoiding that needs the same retirement deferral as same-ledger. +- Each renewal's month starts at its credit (`Service.hs:489-491`). A backlog handed over together forfeits all but one month. +- On Apple, a renewal is filed under whichever profile is active at hand-over (`Store/Badges.hs:139-157`). +- On Play, renewals share one token (unverified), so a renewal resolves to the old record and is never credited (`Commands.hs:5299-5302`). +- Fresh keys buy no unlinkability: the stores link renewals anyway (unverified). + +**Either way.** + +- Step 4 must keep subscription badges out of A's window, or a top-up and a renewal compete for the next credit (`Store/Badges.hs:228`). +- That needs a way to tell a subscription-funded badge apart. The client keeps no product column (`Store/Badges.hs:289`, `:296-301`), though ledger rows store their credit type. + +**The model is split** (judgement). + +- Its ledger example tops up on the same ledger (model line 379). Its same-row upgrade (§2.10) is B's credit, deferred to phase 2 (§7). +- But it creates a row per purchase (model line 417). +- On subscription tier changes, the model makes a new row with new keys (§2.10), while the protocol keeps the key (`badges-rpc.md:50`, `:54`). + +## Subscriptions: the work + +**Crediting time** (judgement, traced through `Ledger.hs:93-116`). + +- A charge learned while coverage runs is added, and issued at the next request. +- A charge learned after coverage ended restarts the run, so coverage drifts by the gap. +- Today there is no request at zero months (`Commands.hs:5689`). An added one would naturally fall a day before the credential expires, 0–7 days after paid-through (`Ledger.hs:240-243`), so drift is the default (inference). +- So the grant should be dated to the store's period start (model O2). +- On an existing ledger, the client's check marks such a grant backdated if it predates the tip (`Ledger.hs:164`). That check changes. +- For a new ledger, the service seeds at now (`Service.hs:489`). Seeding at the period start also needs `signFirstMonth` to lapse first (`Service.hs:489-491`), or it issues an elapsed month. + +**Tier changes.** + +- Apple: a subscription group lets the store's UI change tier (unverified). Play: tiers are separate products (fact, `BadgeStore.kt:31`, `:36`). +- The protocol has the service discover changes (`badges-rpc.md:55`). No such path exists (`Service.hs:341-348`). +- `grantEntry` keeps the tier, so a tier-changing grant is needed. The service signs from the tip's tier (`Service.hs:358`). +- The client stores the tier once (`Store/Badges.hs:297`) and must update it. +- An immediate Apple upgrade refunds the old plan pro rata (unverified), so it needs refund handling. + +**Charges.** + +- Use the commented draft schema (`M20260915_user_badges.hs:155-227`). +- The ledger reader has no charge case (fact, `Ledger.hs:214-225`). A charge row breaks the statement read (`Store.hs:272-290`), so the purchase stops issuing and every reply fails. + +**Discovery.** + +- Use Apple's App Store Server API and Play's `subscriptionsv2` (unverified). +- Reading them when the client asks avoids a background writer. But renewal requests arrive together each week (`Ledger.hs:236-243`; `Commands.hs:5696-5707`), and each store call takes up to 10 s (`StoreReceipts.hs:65`) against a 30 s client deadline. +- A background lane is a second ledger writer. The deployed Postgres runs today's check-then-append at default isolation (`Service.hs:527-533`), so it needs a lock before reading the tip. +- A renewal the app hands over is claimed by its own transaction id (`Commands.hs:5316`), and must be mapped to the original. +- Handed-over Play subscriptions need a verify path: vb checks only one-time purchases (vb `Google.hs:105-107`). +- Store API quotas and raising them are external (unverified). + +**Client core.** + +- At zero months, ask the service whether a charge landed, and keep the badge on until it answers. That needs the store's expiry on the client; no response carries it today (`Badges/Service.hs:121-125`). +- Raise the subscription alerts the code names but never raises (`Badges/Types.hs:120`). + +**Re-bind and restore.** + +- The model's store re-bind is unbuilt. Apple requires a restore mechanism (unverified). +- The ledger reader has no transfer or upgrade case either (`Ledger.hs:212-219`), so re-bind rows would break it like charge rows. + +**Apps.** Subscriptions are not for sale today (fact, `BadgeStore.swift:35`, `BadgeStore.kt:49`). Selling needs the sale screens, hand-over, restore, manage and cancel, and store disclosures. + +## Order, work and waits + +**Order** (judgement), assuming refund clawback is not in the first release (decision 6): + +1. Release the one-time lane. +2. Top-up A, if subscriptions are far off (decision 2). +3. Refund handling. +4. Subscriptions, same-ledger, after the late-landing design. + +Every external item below is unverified: external state cannot be checked from the repo. + +### Step 1: the one-time lane + +**Ours.** + +- Merge `badge-in-app-checkout` and vb. Revert the TEST ONLY URLs (`BadgeStore.swift:42-44`, `BadgeStore.kt:56-61`). +- Deploy the verifiers with a production issuer key at index 1–8 (mq `Crypto/Entitlement.hs:132-143`) and the Apple root certificate. +- Deploy the production service holding the keys of the address compiled into core (`Chat.hs:74`), or replace that address before the release build. It cannot change without an app release. +- Publish a stable simplexmq server release with entitlement support, upgrade our XFTP servers, and configure retention, which is opt-in per server (mq `FileTransfer/Server/Main.hs:166-170`). +- Build the Google Play bundle the repo documents (`apps/multiplatform/README.md:27-28`). No script or CI job builds it (`scripts/android/build-android.sh:137`). +- Spread the weekly renewal requests across their day-long lead, as the TODO at `Commands.hs:5696-5698` designs. +- Drop queued requests older than the client's deadline. The queue records no receive time today (`Service.hs:82`). +- If a load test shows it is needed, take store calls off the serial request loop, where one Play credit can block it for about 20 s (vb `Service.hs:444`, `:468`). Concurrent requests then need the Postgres tip lock. +- A test deployment: a separate service with its own address and the index-9 key, both compiled into core (`Chat.hs:73-74`). That means separate core builds per platform. XFTP retention cannot be tested, since servers trust keys 1–8 only (mq `Agent/Env/SQLite.hs:234`). +- A badge-service section in `PRIVACY.md`; terms of sale and a refund policy for the web checkout. +- The model's prerequisites: a release with the 7-day recipient display grace before purchases ship (model line 518). It is in 7.1 (`Badges.hs:166-182`) but not stable 7.0.4. The model's Monday removal updates are not assumed here; the code removes at paid-through (`Commands.hs:5829-5834`). + +**External.** + +- How 7.0.x reached Play. The `stable` branch already carries the Play Billing flavour (`origin/stable:apps/multiplatform/android/build.gradle.kts:38-42`). If it shipped as a bundle, Play App Signing and a billing build on a track already exist. +- Play App Signing, if not already enrolled (decision 7). The F-Droid recipe must build `assembleFossRelease` (`apps/multiplatform/README.md:33`). +- The 7.1 release with display grace, its store reviews, and recipients updating. +- Apple's Paid Applications Agreement and Google's payments profile, first, since they likely gate all test purchases. +- Products, created only after the product type is settled (risk 1). Play's multi-quantity off. Some products may already exist (`BadgeStore.kt:31`; `BadgeStore.swift:19-23`), and ids cannot be reused (unverified). +- Subscription products created now fix their group and base-plan structure now. Testing subscriptions (risk 6) can use throwaway ids in a separate group. +- Any existing subscription SKUs deactivated. The app finishes or acknowledges a subscription without crediting it (`BadgeStore.swift:196-199`; `BadgeStore.kt:238-241`), so a buyer would be charged and get nothing. +- Play: a Cloud project, a service-account key and two Console permissions (vb README). The order permission likely also allows refunds (unverified), so the test deployment's key needs production custody. +- Store policy and consumer law on holding a paid purchase behind a shown badge, which step 1 already does. +- App Privacy and Data safety declarations, and EU trader status. +- TestFlight beta review and Play testing-track review for test builds. +- Translations by volunteers on Weblate (`README.md:109`). +- Flux: six preset XFTP servers, three chosen at random (`Operators/Presets.hs:118-127`; `Chat.hs:320-337`). Retention must be configured on all six, and they must accept 2 and 5 GB files, which clients allow from launch (`Badges.hs:259-267`). +- Store review and policy: the first in-app purchase, the US link-out, a web-bought code unlocking a perk, and test purchases in review (decision 1). +- The web checkout `badges.simplex.chat`: DNS and TLS, Stripe activation (needs published terms), a BTCPay Server with Bitcoin and Monero (`badges-rpc.md:33`), merchant and tax registration. Deploying the site itself is ours (`scripts/badge-service/Dockerfile:12-18`). +- Sandbox tests: an account-stable `appTransactionId` (risk 3) and a refund of an unfinished purchase (risk 4). +- A Play licence-tester check that a supporter token under the legend product is refused (risk 2). +- Staff purchases on both stores' production paths after approval, since test purchases are refused (vb `Google.hs:140-145`). + +### Step 2: top-up A + +- Store review, with a demo profile near paid-through or a screen recording, if the reviewer tries the new Buy entry. +- Store policy and consumer law on forfeiting the overlap (unverified). +- Translations. + +### Step 3: refunds + +**Ours, all new** (fact: the service has no refund code; the client already decodes refund debits, `Ledger.hs:144`, `:229`). + +- A refund debit write path, an Apple notification endpoint, and a voided-purchase poller. +- They write beside the request loop (`Service.hs:160`, `:181`), so they need the Postgres lock. +- A record of refunded transactions, checked at credit. A refund during a hold has no ledger yet (`Service.hs:455-456`), and Apple's JWS shows a refund only if re-delivered (vb `Apple.hs:122`, `:131`). +- A per-payment debit rule for ledgers holding several payments (B, same-ledger subscriptions). The model's refund debits the whole balance (model lines 297, 347), which would wipe months other payments bought. + +**External.** Play's Voided Purchases API; Apple's Server Notifications V2 with an API key; Stripe disputes. + +### Step 4: subscriptions + +- The App Store subscription group: SKUs, ranking, prices, localization, review, grace period. +- Play subscriptions with grace and account-hold settings. +- Family Sharing (decision 5). +- Pub/Sub for push discovery. +- Terms and privacy links, and translations. + +## Decisions for the lead + +**1. Test purchases in store review.** App Review likely buys with test money (unverified), which is refused, and the app shows "Purchase error" (`BadgeStore.swift:272-276`). Options: + +- Accept test purchases. That gives public TestFlight users free badges (inference, `StoreReceipts.hs:34`). +- Accept them, and restrict public TestFlight meanwhile. +- Keep refusing, with clearer copy and a note to reviewers. Whether review accepts that is unverified; a rejection blocks step 1. + +**2. A, B, both, or neither before subscriptions?** + +- Recommended same-ledger subscriptions need nothing from A, and give store buyers continuity on their own. A is worth building first only if subscriptions are far off. +- B adds to A: one key per badge, no lost overlap, and the model's same-row upgrade. +- B costs: the late-landing fix, a stacking cap or refund handling, months that lapse if the app does not run, and linked payments. + +**3. A's window length, and downgrades.** + +- A longer window is more convenient, but costs more overlap. +- A legend holder buying a supporter month in a 7-day window gives up about $16 of legend for a $7 month (web prices, `Catalog.hs:109-110`). Refuse, warn, or allow? + +**4. Subscribing while a badge is shown?** + +- Not at all: subscriptions need nothing from A, and existing holders wait for retirement. +- Store-funded holders: supersede the badge as A does, losing up to a month. +- Multi-month code holders: forfeit the code's remaining months, use B, or wait. + +**5. Family Sharing for subscriptions?** It cannot be turned off once on (unverified), and creates claimants on other devices. + +**6. Refund clawback in the first release?** + +- The draft model requires it (§7). +- It saves nothing on store one-time months, which are issued at credit. +- It saves a month refunded during a hold, or a web purchase of 3 or 12 months. +- The model's other first-release items are also unbuilt: a Stripe statement descriptor, and receipts (`Service.hs:367`). + +**7. Play App Signing custody**, if not already enrolled: hand Google the signing key, or reverse the documented bundle path. + +## Risks and open questions + +Ranked by cost if wrong. + +**1. The product type** (unverified). + +- Both stores fix it when a product is created. Ids are hard-coded (`Catalog.hs:150-153`; `BadgeStore.kt:28-38`). +- If a month of access must be a non-renewing subscription, restore moves into step 1. +- Settle from the stores' guidance, and check which products exist. + +**2. Owned-product rules and product identity** (unverified). + +- A held Play purchase may block buying its tier on other devices of the same account (`PlayStore.kt:234`). The same question applies to an unfinished iOS consumable. +- vb trusts the client's product id when Play's record omits it (vb `Google.hs:105-107`, `:124`), so cross-tier integrity rests on Play refusing a mismatched token. +- Settle with a licence tester and a Sandbox account. + +**3. Account-stable `appTransactionId`** (unverified). If present, every Apple purchase is linkable anyway, so A's timing linkage matters on Play only. + +**4. Refunds during a hold.** + +- On Apple, a modified client can withhold the revoked transaction. +- An honest one can also lose a race at launch: the receipt worker may present the stored JWS before the sweep replaces it (`Commands.hs:269`; `SimpleXAPI.swift:2397`). +- Only step 3 closes this. The loss equals today's refund-after-credit baseline. + +**5. Synchronised renewal requests** (inference). + +- Every request fires a day before the shared weekly expiry, at the same instant on Android and desktop. +- The service answers one at a time (`Service.hs:267-272`) against a 30 s client deadline. Per-request time is unmeasured. +- The queue has no age check, so a backlog serves clients that have already gone. +- Spreading and dropping stale requests (step 1) is the fix; a load test sizes it. + +**6. Renewal visibility and revival** (unverified). Whether Play renewals reach the app, which Apple fields persist, and how subscriptions are revived. Settle with test subscriptions. + +**7. The late-landing design for same-ledger subscriptions.** The only worked fix is broad. A narrower one, crediting a revival after grace as a fresh purchase, is unexamined. + +**8. Open checks.** + +- How 7.0.x reached Play. +- Whether a subscription can move between groups. +- Whether App Review accepts "Purchase error". +- Store API quotas. +- Binding a store payment to its purchase key, against another device's claim. The invoice id the apps send is a random UUID any device can read back (`Commands.hs:3590`; `BadgeStore.swift:372`), so this needs a key-derived token and a protocol change. diff --git a/plans/2026-10-10-desktop-address-no-profile-crash.md b/plans/2026-10-10-desktop-address-no-profile-crash.md new file mode 100644 index 0000000000..c51eeaff04 --- /dev/null +++ b/plans/2026-10-10-desktop-address-no-profile-crash.md @@ -0,0 +1,99 @@ +# Crash in the SimpleX address screen when there is no profile + +## Problem + +A Windows user who uses the desktop app only through a linked mobile reported this crash: + +``` +java.lang.NullPointerException + at chat.simplex.common.views.chatlist.ComposableSingletons$UserPickerKt$lambda$-1608374373$1.invoke(UserPicker.kt:223) + at chat.simplex.common.views.chatlist.UserPickerKt$UserPicker$6$showCustomModal$1$1$1.invoke(UserPicker.kt:157) + at chat.simplex.common.views.helpers.ModalManager$showInView$4$1$1.invoke(ModalView.kt:218) +``` + +The "SimpleX address" screen crashes whenever it is shown while there is no active user. + +## Solution summary + +When there is no current user, the address modal closes itself instead of composing `UserAddressView`. It closes only while it is the top modal, so it does not close a different screen. The new `ModalManager.isLastModal` checks that. + +The "Chat preferences" row, which in paths 1 and 2 below left an invisible modal instead of crashing, closes itself the same way. + +## Technical design + +### Cause + +The "SimpleX address" row of the user picker opens `UserAddressView` with `shareViaProfile = it.currentUser.value!!.addressShared`. The modal content reads `currentUser`, so it recomposes whenever `currentUser` changes. It throws whenever it is composed while `currentUser` is null, that is, when there is no active user. A desktop that was only used with a linked mobile has no active user of its own. + +The screen can be shown in that state in three ways: + +1. **The mobile disconnects.** The screen was opened for the mobile's profile, and then the mobile disconnects. `switchUIRemoteHost(null)` sets `currentUser` to the local user, which is null, and the left-panel modals stay open. + - If the address screen is on top, it crashes when it recomposes. + - If another screen is on top of it, it crashes when the user goes back to it. + + `UserAddressView` closes itself when the user changes (`KeyChangeEffect` on the user), but with a null user it is never reached. +2. **The row is clicked with no active user.** With no active profile, the user picker still shows the "Create SimpleX address" row. With no profile and no mobile in use, the picker even opens by itself, at startup or when the state begins while no left-panel screen is open (`App.kt`, `desktopNoUserNoRemote`). Clicking the row crashes immediately. + + At startup, no profile and no connected mobile needs a mobile that was linked before; with no linked mobile, startup shows onboarding instead (`Core.kt`). + + No active user also follows from deleting the active profile when no other visible profile remains, including the only profile, and including the last visible profile of a connected mobile. `doRemoveUser` (`UserProfilesView.kt`) then calls `changeActiveUser_` with no user, which sets `currentUser` from `apiGetActiveUser`. After the deletion there is no active user, so it is null. + +3. **The self-destruct passcode is entered while the screen is open.** `deleteStorageAndRestart` (`LocalAuthView.kt`) calls `reinitChatController`. That calls `initChatController`, which sets `currentUser` from the new empty database to null (`Core.kt`). Only later does `deleteStorageAndRestart` create the new profile and call `closeAllModalsEverywhere`. + - On Android, the shared modal stack stays composed under the lock screen, so the address screen can recompose with a null user and throw. It throws only if a frame falls after `currentUser` becomes null and before onboarding replaces the main screen, so it does not happen on every run. Android's main loop catches exceptions (`SimplexApp.kt`). `GlobalExceptionsHandler` (`UI.android.kt`) then closes the top modal, recreates the activity and posts a crash alert, and the self-destruct flow hides all alerts at its end, so no crash is visible. + - On desktop, `initChatController` replaces the main screen with the splash screen shortly afterwards (`localUserCreated = null`), so the crash needs a frame to fall in that short window. This was not tested. + +The "Chat preferences" row has the same cause without the crash. It opens `PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)`, so with no active user the modal content returns early. `PreferencesView` closes itself when the user changes (`KeyChangeEffect` on the user, `Preferences.kt`), but with a null user it is never reached either. The modal stays on the left-panel stack with nothing drawn and no back button, in paths 1 and 2 above. + +### Self-closing modal + +Both rows use the same effect. `ModalManager.closeModal` closes the top modal that is not already being removed, whichever modal calls it. So the effect closes only when `ModalManager.isLastModal(data)` reports that this modal is that one. + +The effect is keyed on the modal and on the modal count of `ModalManager.start` (the left panel on desktop, the shared stack on Android). So it checks again when the count changes, or when its composition is reused for another modal, while this content is still composed. Other than the top modal, a modal stays composed only while it animates out (250 ms, longer if the animation is interrupted). For example, a quick second click on the row reuses the composition of the first modal, which is still animating out. + +A modal that is covered after an animation ends is disposed. It is composed afresh when it becomes the top again, for example when the user goes back to it, and the effect runs then. + +With the `isLastModal` check, the effect does not close a different screen, such as one opened just before or just after the address screen during an animation. This holds for stack changes on the main thread. As before, `ModalManager` is not synchronized with background calls such as `closeAllModalsEverywhere`. + +### Visible behaviour + +When there is no active user: +- Clicking either row now only hides the picker, as opening any modal does. The modal closes itself, and the picker is not reopened. +- After the mobile disconnects with the address or preferences screen open, the picker does not reopen by itself (observed in testing). When the state with no user and no mobile in use begins, the app checks for open left-panel modals, and the modal closes itself only after that check. Master does the same when any other left-panel screen is open. + +The change is in common code. On Android the user picker is not reachable without an active user, because deleting the last visible profile returns to onboarding. In the self-destruct case on Android, the screen can no longer throw, so `GlobalExceptionsHandler` does not have to recover. With an active user, behaviour does not change on any platform. + +### Security + +The change is UI-only and crosses no trust boundary: it reads local state and closes a local screen. It sends nothing and changes no stored data. + +### Rejected alternative + +An early `return@showCustomModal`, as the "Chat preferences" row did, would avoid the crash. But it would leave an invisible modal on the left-panel stack, with no back button. It stays until something closes the left-panel modals, such as a click on the centre panel. If a profile is created first, it comes back as the address screen. The "Chat preferences" row has this behaviour on master; this change gives it the self-closing effect too. + +## Implementation steps + +1. `views/helpers/ModalView.kt`: add `ModalManager.isLastModal(data)`, true when `data` belongs to the top modal that is not being removed. +2. `views/chatlist/UserPicker.kt`: in the "SimpleX address" row, compose `UserAddressView` only when `currentUser` is not null. Otherwise run `LaunchedEffect(modal, ModalManager.start.modalCount.value)`, which closes the modal when `isLastModal(modal)` is true. Do the same in the "Chat preferences" row for `PreferencesView`. +3. Documentation, under `apps/multiplatform/`: + - `spec/client/navigation.md`: list `isLastModal` among the `ModalManager` methods. + - `spec/client/chat-list.md`, UserPicker section: describe the address and preferences rows closing themselves with no active user. + - `product/views/user-profiles.md`: describe both rows with no active profile. + +## Verification + +Desktop AppImages from master and from this branch, each with a fresh database, unless stated otherwise. The v7.1.0-beta.6 CLI (core v7.1.0.10) acts as the linked mobile (`/connect remote ctrl`). "Leftover" is checked by creating a profile afterwards: a leftover modal reappears as the address screen when onboarding finishes. + +- master (at the merge base), case 1: NPE at `UserPicker.kt:223`. +- master (at the merge base), case 2: NPE at `UserPicker.kt:223` with the reported stack (`showCustomModal` 157, `ModalView.kt:218`). +- This branch, case 1 with the address screen on top: no crash, and the screen closes. +- This branch, case 1 with the "Address or 1-time link?" screen (opened from "SimpleX address or 1-time link?") on top: no crash. That screen stays open after the disconnect, as on master. Going back closes the address screen and shows the chat list. +- This branch, case 2, double click (seven runs, 8 to 40 ms apart): no crash, and no leftover. With `LaunchedEffect(Unit)` the address screen reappeared after onboarding in 2 of 3 runs at 30 ms. +- This branch, case 2, "Settings" then "Create SimpleX address" 100 ms apart: no crash, and Settings stays open. With the close keyed on the modal count but without the `isLastModal` check, Settings was closed too. +- This branch, with a local profile: the address screen opens and closes normally. When the mobile disconnects with its address screen open, the screen closes through `UserAddressView`'s own user-change effect, and the app returns to the local profile. +- "Chat preferences", desktop with no profile, the same CLI linked as the mobile: + - Preferences open, then the mobile disconnects. Before this change the screen disappeared, and after a profile was created it came back as "Your preferences" over the chat. With this change it does not come back, and there is no crash. + - Row clicked with no profile and no mobile: nothing opens, and nothing comes back after a profile is created. + - With a profile: the screen opens and closes normally. +- Android (Waydroid: Android 13 with arm64 translation), debug APKs of master and of this branch, fresh app data, case 3 with the address screen opened from the user picker: + - master, two runs: in one, logcat shows `App crashed, thread name: main` with the NPE at `UserPicker.kt:223`; in the other, no exception. No crash is visible in either, as in an earlier manual test. + - This branch: one run completed to the new profile with no exception in logcat. As master did not throw on every run, this checks for no new failure rather than proving the fix. An earlier run hung during self-destruct, with the main thread, a coroutine thread and a Haskell worker all waiting for the native allocator lock under arm64 translation. diff --git a/plans/2026-10-10-invite-member-via-group.md b/plans/2026-10-10-invite-member-via-group.md new file mode 100644 index 0000000000..ecc9ab06af --- /dev/null +++ b/plans/2026-10-10-invite-member-via-group.md @@ -0,0 +1,612 @@ +# Inviting group members to other groups: implementation plan + +RFC: `docs/rfcs/2026-10-10-invite-member-via-group.md`. + +Branch: `ep/group-inv`. + +Line numbers: `d93fb626b` (`ep/group-inv` after merging `master`), before any change in this plan. + +## 1. Protocol version + +`src/Simplex/Chat/Protocol.hs`. + +Version 22 is shared with owner-signed relay invitations. + +Version history comment, after line 89: + +```haskell +-- 21 - command keywords with any characters (2026-09-27) +-- 22 - owner-signed relay invitations, group invitations via member connections (2026-10-10) +``` + +`currentChatVersion` (line 95): unchanged, `VersionChat 22`. + +`signedRelayInvVersion` (lines 146-147) renamed: + +```haskell +groupInvVersion :: VersionChat +groupInvVersion = VersionChat 22 +``` + +Uses: + +| File | Location | Use | +|---|---|---| +| `src/Simplex/Chat/Library/Subscriber.hs` | line 1657 | `xGrpRelayInv`, renamed reference | +| `src/Simplex/Chat/Library/Commands.hs` | section 5.1 | `APIAddMemberViaGroup` | + +Event types, tags, parsing and encoding: unchanged. + +## 2. Migration + +Files: + +- `src/Simplex/Chat/Store/SQLite/Migrations/M20261010_invite_member_via_group.hs` +- `src/Simplex/Chat/Store/Postgres/Migrations/M20261010_invite_member_via_group.hs` + +SQLite up: + +```sql +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +``` + +Postgres up: + +```sql +ALTER TABLE group_members ADD COLUMN invited_via_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE SET NULL; +CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members(invited_via_group_member_id); +``` + +Down, both: + +```sql +DROP INDEX idx_group_members_invited_via_group_member_id; +ALTER TABLE group_members DROP COLUMN invited_via_group_member_id; +``` + +Registration: + +| File | Import | List entry | +|---|---|---| +| `src/Simplex/Chat/Store/SQLite/Migrations.hs` | after line 179 | after line 356 | +| `src/Simplex/Chat/Store/Postgres/Migrations.hs` | after line 56 | after line 110 | +| `simplex-chat.cabal` | after line 171 (Postgres) | after line 349 (SQLite) | + +Regenerated by tests: + +| File | Test | +|---|---| +| `src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql` | "Schema dump" | +| `src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt` | "Save query plans", last in the full test run | +| `src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql` | "Postgres schema dump", Postgres build; edited by hand | + +## 3. Command, response and event types + +`src/Simplex/Chat/Controller.hs`. + +`ChatCommand`, after line 467: + +```haskell + | APIAddMemberViaGroup {groupId :: GroupId, viaGroupId :: GroupId, groupMemberId :: GroupMemberId, memberRole :: GroupMemberRole} +``` + +`ChatResponse`, after line 879: + +```haskell + | CRSentGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, member :: GroupMember, viaGroupInfo :: GroupInfo, viaMember :: GroupMember} +``` + +`ChatEvent`, after line 1030: + +```haskell + | CEvtReceivedGroupInvitationViaGroup {user :: User, groupInfo :: GroupInfo, viaGroupInfo :: GroupInfo, viaMember :: GroupMember, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} +``` + +`APIAddMember`, `CRSentGroupInvitation` and `CEvtReceivedGroupInvitation`: unchanged. + +## 4. Store + +`src/Simplex/Chat/Store/Groups.hs`. + +### 4.1. `createGroupInvitation` (line 458) + +```haskell +createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Either Contact GroupMember -> VersionRangeChat -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) +createGroupInvitation db cxt user@User {userId} inviter peerChatVRange GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do +``` + +Equation for `activeConn = Nothing` (line 459): removed. +Peer range: parameter, from the inviter connection. + +| Line | `Left ct` | `Right m` | +|---|---|---| +| 504 | `createContactMemberInv_`, unchanged | host row, see below | +| 505 | membership `invitedBy = IBContact contactId` | membership `invitedBy = IBUnknown` | + +Lines 504-505: + +```haskell + (groupMemberId, invitedBy) <- case inviter of + Left contact@Contact {contactId} -> do + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing ((\(MemberKey k) -> k) <$> fromMemberKey) currentTs hostVRange + pure (groupMemberId, IBContact contactId) + Right GroupMember {memberProfile} -> do + (hostName, memProfileId, hostProfile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing currentTs + let MemberIdRole {memberId, memberRole} = fromMember + host = + NewGroupMember + { memInfo = MemberInfo memberId memberRole (Just $ ChatVersionRange hostVRange) hostProfile fromMemberKey, + memCategory = GCHostMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUnknown, + memInvitedByGroupMemberId = Nothing, + localDisplayName = hostName, + memContactId = Nothing, + memProfileId + } + GroupMember {groupMemberId} <- createNewMember_ db user groupId host badgeVerified currentTs + pure (groupMemberId, IBUnknown) + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited invitedBy incognitoProfileId (Just $ fst memberKeys) currentTs (vr cxt) +``` + +Branch for an existing group (`getInvitationGroupId_`): unchanged. + +`createNewMember_` (line 2457): `GroupInfo` parameter → `GroupId`. +Calls in `createNewGroupMember` (line 2444) and `createIntroReMember` (line 2642): `(groupId' gInfo)`. + +### 4.2. `createNewMemberViaGroup` + +New function, after `createNewContactMember` (line 1379), before `createGroupRelayRecord` (line 1439). + +```haskell +createNewMemberViaGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupInfo -> GroupMember -> Connection -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createNewMemberViaGroup db gVar cxt user@User {userId} GroupInfo {groupId, membership} viaMember@GroupMember {memberProfile} Connection {connChatVersion, peerChatVRange} memberRole agentConnId connRequest subMode = + createWithRandomId' db gVar $ \memId -> runExceptT $ do + createdAt <- liftIO getCurrentTime + (localDisplayName, memProfileId, profile, badgeVerified) <- createNewMemberProfile_ db cxt user (fromLocalProfile memberProfile) Nothing createdAt + let newMember = + NewGroupMember + { memInfo = MemberInfo (MemberId memId) memberRole (Just $ ChatVersionRange peerChatVRange) profile Nothing, + memCategory = GCInviteeMember, + memStatus = GSMemInvited, + memRestriction = Nothing, + memInvitedBy = IBUser, + memInvitedByGroupMemberId = Just $ groupMemberId' membership, + localDisplayName, + memContactId = Nothing, + memProfileId + } + member@GroupMember {groupMemberId} <- createNewMember_ db user groupId newMember badgeVerified createdAt + liftIO $ do + DB.execute + db + "UPDATE group_members SET sent_inv_queue_info = ?, invited_via_group_member_id = ? WHERE group_member_id = ?" + (connRequest, groupMemberId' viaMember, groupMemberId) + void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode + pure member +``` + +### 4.3. `getMemberInvitedVia` + +New function, after `getMemberInvitation` (line 2237). + +```haskell +getMemberInvitedVia :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> IO (Maybe GroupMember) +getMemberInvitedVia db cxt user@User {userId} GroupInfo {groupId} viaMember = do + currentTs <- getCurrentTime + maybeFirstRow (toContactMember currentTs cxt user) $ + DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.invited_via_group_member_id = ? AND m.member_status NOT IN (?,?,?)") + (userId, groupId, groupMemberId' viaMember, GSMemRejected, GSMemRemoved, GSMemLeft) +``` + +Excluded statuses: as `contactMember` (`Commands.hs` line 4008). + +## 5. Sending + +`src/Simplex/Chat/Library/Commands.hs`. + +### 5.1. `APIAddMemberViaGroup` handler + +After `APIAddMember` (line 2814), before `APIJoinGroup` (line 2844). + +```haskell + APIAddMemberViaGroup groupId viaGroupId gmId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do + (g@(GIK gInfo _), viaGInfo, m) <- withFastStore $ \db -> + (,,) <$> getGroupInfoKeys db cxt user groupId <*> getGroupInfo db cxt user viaGroupId <*> getGroupMember db cxt user viaGroupId gmId + let GroupMember {localDisplayName = mName} = m + when (viaGroupId == groupId) $ throwChatError $ CEGroupDuplicateMember mName + when (useRelays' gInfo) $ throwCmdError "can't invite member to channel" + when (useRelays' viaGInfo) $ throwCmdError "can't invite member from channel" + assertUserGroupRole gInfo $ max GRAdmin memRole + assertUserGroupRole viaGInfo GRAuthor + unless (groupFeatureUserAllowed SGFDirectMessages viaGInfo) $ throwCmdError "direct messages not allowed" + when (incognitoMembership viaGInfo) $ throwChatError CEContactIncognitoCantInvite + when (incognitoMembership gInfo) $ throwChatError CEGroupIncognitoCantInvite + mConn <- case memberConn m of + Just conn | memberCurrent m -> pure conn + _ -> throwChatError CEGroupMemberNotActive + unless (m `supportsVersion` groupInvVersion) $ throwChatError CEPeerChatVRangeIncompatible + let sendInvitation = sendGrpInvitationViaGroup user viaGInfo m mConn g + withFastStore' (\db -> getMemberInvitedVia db cxt user gInfo m) >>= \case + Nothing -> do + gVar <- asks random + subMode <- chatReadVar subscriptionMode + (agentConnId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff True subMode + member <- withFastStore $ \db -> createNewMemberViaGroup db gVar cxt user gInfo m mConn memRole agentConnId cReq subMode + sendInvitation member cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member viaGInfo m + Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} + | memberStatus == GSMemInvited -> do + unless (mRole == memRole) $ withFastStore' $ \db -> updateGroupMemberRole db user member memRole + withFastStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case + Just cReq -> do + sendInvitation member {memberRole = memRole} cReq + pure $ CRSentGroupInvitationViaGroup user gInfo member {memberRole = memRole} viaGInfo m + Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo mName + | otherwise -> throwChatError $ CEGroupDuplicateMember mName +``` + +### 5.2. `sendGrpInvitationViaGroup` + +New function, after `sendGrpInvitation` (line 4301). +`sendGrpInvitation`: unchanged. +`localDisplayName` in the item: name of the member in group A. + +```haskell + sendGrpInvitationViaGroup :: User -> GroupInfo -> GroupMember -> Connection -> GroupInfoKeys -> GroupMember -> ConnReqInvitation -> CM () + sendGrpInvitationViaGroup user viaGInfo GroupMember {localDisplayName} mConn (GIK gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} gks) GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership + groupInv = + GroupInvitation + { fromMember = MemberIdRole userMemberId userRole, + fromMemberKey = Just $ groupMemberKey gks, + invitedMember = MemberIdRole memberId memRole, + connRequest = cReq, + groupProfile, + business = businessChat, + groupLinkId = Nothing, + groupSize = Just currentMemCount + } + (msg, _, _) <- sendDirectMemberMessage mConn (XGrpInv groupInv) (groupId' viaGInfo) + let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole + ci <- saveSndChatItem user (CDGroupSnd viaGInfo Nothing) msg content + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat viaGInfo Nothing) ci] +``` + +### 5.3. Parser + +After line 6172: + +```haskell + "/_add #" *> (APIAddMemberViaGroup <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <*> memberRole), +``` + +## 6. Receiving + +`src/Simplex/Chat/Library/Subscriber.hs`. + +### 6.1. Group event dispatch + +After line 1123: + +```haskell + XGrpInv gInv -> Nothing <$ xGrpInv gInfo' m'' conn' gInv msg brokerTs +``` + +### 6.2. `processGroupInvitation` (line 2700) + +| Line | After | +|---|---| +| 2706 | `forM_ activeConn $ \conn@Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do` | +| 2711 | `(g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Left ct) peerChatVRange inv customUserProfileId memberKeys` | +| 2714-2726 | local `joinGroupAsync` and its comment: moved to 6.3 | +| 2734 | `joinGroupAsync g hostId conn connRequest (Just ct) True` | +| 2738 | `joinGroupAsync g hostId conn connRequest Nothing False` | + +### 6.3. `joinGroupAsync` + +Moved from `processGroupInvitation` to the `where` block of `processAgentMessageConn`, after `processGroupInvitation`. +Comment and body: unchanged. + +```haskell + joinGroupAsync :: GroupInfoKeys -> GroupMemberId -> Connection -> ConnReqInvitation -> Maybe Contact -> Bool -> CM () + joinGroupAsync (GIK gInfo@GroupInfo {groupId, membership} gks) hostId Connection {connId, connChatVersion, peerChatVRange} connRequest hostContact_ sameLink = do +``` + +### 6.4. `xGrpInv` + +New function, before `xGrpDirectInv` (line 3885). + +```haskell + xGrpInv :: GroupInfo -> GroupMember -> Connection -> GroupInvitation -> RcvMessage -> UTCTime -> CM () + xGrpInv viaGInfo m mConn@Connection {peerChatVRange} inv msg brokerTs + | useRelays' viaGInfo = messageError "x.grp.inv: not allowed in channels" + | isJust publicGroup = messageError "x.grp.inv: can't invite to channel" + | not (groupFeatureMemberAllowed SGFDirectMessages m viaGInfo) = messageError "x.grp.inv: direct messages not allowed" + | memberBlocked m = messageWarning "x.grp.inv: member is blocked (ignoring)" + | fromRole < GRAdmin || fromRole < memRole = messageError "x.grp.inv: inviting member role" + | fromMemId == memId = messageError "x.grp.inv: duplicate member ID" + | otherwise = do + memberKeys <- atomically . C.generateKeyPair =<< asks random + let incognitoProfileId = localProfileId <$> incognitoMembershipProfile viaGInfo + (g@(GIK gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership} _), hostId) <- withStore $ \db -> createGroupInvitation db cxt user (Right m) peerChatVRange inv incognitoProfileId memberKeys + void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) + let GroupMember {groupMemberId} = membership + createInvitationItem invStatus = do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv viaGInfo Nothing m) msg brokerTs content + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + groupMsgToView cInfo ci + if isTrue (autoAcceptGroupInvitations user) + then when (memberStatus membership == GSMemInvited) $ do + joinGroupAsync g hostId mConn connRequest Nothing False + createInvitationItem CIGISAccepted + else do + createInvitationItem CIGISPending + toView CEvtReceivedGroupInvitationViaGroup {user, groupInfo = gInfo, viaGroupInfo = viaGInfo, viaMember = m, fromMemberRole = fromRole, memberRole = memRole} + where + GroupInvitation {fromMember = MemberIdRole fromMemId fromRole, invitedMember = MemberIdRole memId memRole, connRequest, groupProfile = GroupProfile {publicGroup}} = inv +``` + +### 6.5. Profiles after joining + +The profile copies from 4.1 and 4.2 are replaced during the connection handshake, by existing code: + +| Side | Message | Handler | +|---|---|---| +| inviting client | `x.grp.acpt` with the profile of the invited member | `processMemberProfileUpdate` (line 805) | +| invited client | `x.grp.mem.info` with the profile of the inviting member | `processMemberProfileUpdate` (line 866) | + +## 7. Joining and invitation status + +`src/Simplex/Chat/Library/Commands.hs`. + +### 7.1. `APIJoinGroup` (line 2844) + +Lines 2846-2848: + +```haskell + (invitation, ct_) <- withFastStore $ \db -> do + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db cxt user groupId + (inv,) <$> forM (memberContactId fromMember) (\_ -> getContactViaMember db cxt user fromMember) +``` + +Line 2850, `Contact {activeConn} = ct` replaced: + +```haskell + peerChatVRange_ = case ct_ of + Nothing -> Right $ memberChatVRange' fromMember + Just ct@Contact {activeConn} -> maybe (Left ct) (\Connection {peerChatVRange} -> Right peerChatVRange) activeConn +``` + +| Line | Before | After | +|---|---|---| +| 2851 | `case activeConn of` | `case peerChatVRange_ of` | +| 2852 | `Just Connection {peerChatVRange} -> do` | `Right peerChatVRange -> do` | +| 2875 | `Nothing -> throwChatError $ CEContactNotActive ct` | `Left ct -> throwChatError $ CEContactNotActive ct` | + +Lines 2853-2872: unchanged. + +Line 2873, missing invitation item ignored: + +```haskell + updateCIGroupInvitationStatus user g CIGISAccepted `catchAllErrors` \case + ChatErrorStore SEChatItemNotFoundByGroupId {} -> pure () + e -> eToView e +``` + +Invitation from a contact deleted before joining (`contact_id` set to NULL, `Store/Direct.hs` line 492): the member path is taken; master returned `SEContactNotFoundByMemberId`. +The invitation item is deleted with the contact (`Store/Direct.hs` line 483). + +### 7.2. `updateCIGroupInvitationStatus` (line 4776) + +Line 4778, item binding: `AChatItem _ _ cInfo ci@ChatItem {content, meta = meta@CIMeta {itemId}}`. + +New alternative, after line 4786. +Timer: as the direct alternative (lines 4783-4786), from the preferences of group A. + +```haskell + (GroupChat g _, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) + | status == CIGISPending -> do + let content' = CIRcvGroupInvitation (ciGroupInv {status = newStatus} :: CIGroupInvitation) memRole + timed_ <- sndGroupCITimed False g Nothing + ci' <- withFastStore' $ \db -> updateGroupChatItem db user (groupId' g) ci {meta = meta {itemTimed = timed_}} content' False False Nothing + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci') + forM_ (timed_ >>= timedDeleteAt') $ + startProximateTimedItemThread user (ChatRef CTGroup (groupId' g) Nothing, itemId) +``` + +## 8. Terminal + +### 8.1. `src/Simplex/Chat/View.hs` + +| Line | Change | +|---|---| +| after 210 | `CRSentGroupInvitationViaGroup u g _ viaG viaM -> ttyUser u ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyMember viaM <> " in " <> ttyGroup' viaG]` | +| 517 | `viewReceivedGroupInvitation g (ttyContact' c) r` | +| after 517 | `CEvtReceivedGroupInvitationViaGroup {user = u, groupInfo = g, viaGroupInfo = viaG, viaMember = m, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g (ttyMember m <> " in " <> ttyGroup' viaG) r` | +| 735 | `CISndGroupInvitation {} -> showSndItemProhibited to` removed | +| 746 | `CIRcvGroupInvitation {} \| isJust m_ -> showRcvItemProhibited from` removed | +| 1405 | `viewReceivedGroupInvitation` takes the inviter name as `StyledString` in place of `Contact` | + +Output for an invitation from a member: + +``` +#club: alice in #team invites you to join the group as member +use /j club to accept +``` + +### 8.2. `src/Simplex/Chat/Terminal/Output.hs` + +After line 200: + +```haskell + CEvtReceivedGroupInvitationViaGroup u g viaG m _ _ -> + when (groupNtf u viaG False) $ + sendNtf ("#" <> viewGroupName g <> " " <> viewMemberName m <> "> ", "invited you to join the group") +``` + +### 8.3. `src/Simplex/Chat/Terminal/Input.hs` + +After line 84: + +```haskell + CRSentGroupInvitationViaGroup u g _ _ _ -> whenCurrUser cc u $ setActiveGroup ct g +``` + +## 9. Directory service + +Unchanged: `APIAddMember` and `CEvtReceivedGroupInvitation` are unchanged. + +## 10. Bots API + +### 10.1. Docs + +`bots/src/API/Docs/Commands.hs`, after line 121: + +```haskell + ("APIAddMemberViaGroup", [], "Add member of another group to group. Requires bot to have Admin role in the group, and direct messages allowed for its role in another group.", ["CRSentGroupInvitationViaGroup", "CRChatCmdError"], [TD "CEPeerChatVRangeIncompatible" "Member's client version is older than required"], Just UNInteractive, "/_add #" <> Param "groupId" <> " #" <> Param "viaGroupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), +``` + +`bots/src/API/Docs/Responses.hs`, after line 94: + +```haskell + ("CRSentGroupInvitationViaGroup", "Group invitation sent to member of another group"), +``` + +`bots/src/API/Docs/Events.hs`, after line 87: + +```haskell + ("CEvtReceivedGroupInvitationViaGroup", "Received group invitation from member of another group."), +``` + +Regenerated by `tests/APIDocs.hs`: + +- `bots/api/COMMANDS.md` +- `bots/api/EVENTS.md` +- `packages/simplex-chat-client/types/typescript/src/commands.ts` +- `packages/simplex-chat-client/types/typescript/src/responses.ts` +- `packages/simplex-chat-client/types/typescript/src/events.ts` +- `packages/simplex-chat-python/src/simplex_chat/types/_commands.py` +- `packages/simplex-chat-python/src/simplex_chat/types/_responses.py` +- `packages/simplex-chat-python/src/simplex_chat/types/_events.py` + +### 10.2. Wrappers + +New wrapper `apiAddMemberViaGroup`, after `apiAddMember`: + +| File | After line | Function | +|---|---|---| +| `packages/simplex-chat-nodejs/src/api.ts` | 565 | `apiAddMemberViaGroup(groupId, viaGroupId, groupMemberId, memberRole): Promise` | +| `packages/simplex-chat-python/src/simplex_chat/api.py` | 352 | `api_add_member_via_group(group_id, via_group_id, group_member_id, member_role)` | +| `packages/simplex-chat-client/typescript/src/client.ts` | 262 | `apiAddMemberViaGroup(groupId, viaGroupId, groupMemberId, memberRole)` | + +Command: `CC.APIAddMemberViaGroup.cmdString({groupId, viaGroupId, groupMemberId, memberRole})`. +Response check: type `sentGroupInvitationViaGroup`, result `member`. +JSDoc in `api.ts`: the command description from 10.1, then "Network usage: interactive." + +## 11. Apps + +Sending: unchanged. + +### 11.1. Kotlin + +`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`: + +| Line | Change | +|---|---| +| after 6817 | `@Serializable @SerialName("receivedGroupInvitationViaGroup") class ReceivedGroupInvitationViaGroup(val user: UserRef, val groupInfo: GroupInfo, val viaGroupInfo: GroupInfo, val viaMember: GroupMember, val memberRole: GroupMemberRole): CR()` | +| after 7017 | `is ReceivedGroupInvitationViaGroup -> "receivedGroupInvitationViaGroup"` | +| after 7208 | `is ReceivedGroupInvitationViaGroup -> withUser(user, "groupInfo: $groupInfo\nviaGroupInfo: $viaGroupInfo\nviaMember: $viaMember\nmemberRole: $memberRole")` | + +Handler, after line 3130: + +```kotlin + is CR.ReceivedGroupInvitationViaGroup -> { + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + } + } + } +``` + +Card label: + +| File | Line | Change | +|---|---|---| +| `apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml` | after 1769 | `For you only` | +| `apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml` | after 1769 | `For %1$s only` | +| `.../views/chat/item/CIGroupInvitationView.kt` | after 31 | parameter `groupChat: Boolean = false,` | +| `.../views/chat/item/CIGroupInvitationView.kt` | after 113 | `if (groupChat) {` / `Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor)` / `}` | +| `.../views/chat/item/ChatItemView.kt` | 723, 727 | argument `groupChat = cInfo is ChatInfo.Group` | + +### 11.2. Swift + +`apps/ios/Shared/Model/AppAPITypes.swift`: + +| Line | Change | +|---|---| +| after 1193 | `case receivedGroupInvitationViaGroup(user: UserRef, groupInfo: GroupInfo, viaGroupInfo: GroupInfo, viaMember: GroupMember, memberRole: GroupMemberRole)` | +| after 1274 | `case .receivedGroupInvitationViaGroup: "receivedGroupInvitationViaGroup"` | +| after 1358 | `case let .receivedGroupInvitationViaGroup(u, groupInfo, viaGroupInfo, viaMember, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nviaGroupInfo: \(viaGroupInfo)\nviaMember: \(viaMember)\nmemberRole: \(memberRole)")` | + +Handler, `apps/ios/Shared/Model/SimpleXAPI.swift`, line 2708, second pattern in the `receivedGroupInvitation` case: + +```swift + case let .receivedGroupInvitation(user, groupInfo, _, _), + let .receivedGroupInvitationViaGroup(user, groupInfo, _, _, _): +``` + +Card label, `apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`, after line 41: + +```swift + if chat.chatInfo.groupInfo != nil { + Text(chatItem.chatDir.sent ? String.localizedStringWithFormat(NSLocalizedString("For %@ only", comment: ""), groupInvitation.localDisplayName) : NSLocalizedString("For you only", comment: "")) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .overlay(DetermineWidth()) + } + +``` + +## 12. Protocol docs + +`docs/protocol/simplex-chat.md`, new paragraph after the `x.grp.inv` paragraph (line 233): + +``` +`x.grp.inv` message can also be sent to a group member via the member connection, to invite the member to another group. In this case, the message MUST only be sent when direct messages are allowed for the sender in the group of the member connection, and receiving clients MUST ignore it otherwise. +``` + +## 13. Tests + +`tests/ChatTests/Groups.hs`, new `describe "invite member via group"`, command `/_add # # member`: + +| Test | Setup | Expected | +|---|---|---| +| `testInviteMemberViaGroup` | alice owner of #team and #club; bob and cath in #team | invitation cards in #team; `cath ` and `/a`: unchanged; they cover the `APIAddMember` parser next to the new `/_add` alternative. + +`tests/APIDocs.hs`: run to regenerate the files in 10.1. + +## 14. Version + +Version 22 is not in any release: v7.1.0-beta.6 uses version 21. +If a release with version 22 ships before this feature, a separate constant with version 23 is added for this feature, and `currentChatVersion` is raised to 23. diff --git a/plans/2026-10-11-core-libs-releases.md b/plans/2026-10-11-core-libs-releases.md new file mode 100644 index 0000000000..e6f9a1347b --- /dev/null +++ b/plans/2026-10-11-core-libs-releases.md @@ -0,0 +1,182 @@ +# Core library releases: implementation plan + +Libraries for the Node.js package are released per core version, from the commit on `master` or `stable` that changes the version. + +## 1. Release identity + +``` +repository simplex-chat/simplex-chat-libs +tag core- +version version: field of simplex-chat.cabal, e.g. 7.1.0.10 +build push to master or stable, head commit changes version: compared with its first parent +source the head commit of that push +``` + +A version change that is not the head commit of its push is not released. + +Rule: a core version is final once a published package depends on it. +Its migrations and chat protocol versions stay unchanged. +A further change requires a new cabal version. + +## 2. Assets + +| Asset | Job | Backend | +|---|---|---| +| `simplex-chat-libs-linux-x86_64.zip` | `build-linux`, 22.04 x86_64 | sqlite | +| `simplex-chat-libs-linux-x86_64-postgres.zip` | `build-linux-postgres` | postgres | +| `simplex-chat-libs-linux-aarch64.zip` | `build-linux`, 22.04 aarch64 | sqlite | +| `simplex-chat-libs-macos-aarch64.zip` | `build-macos`, `macos-latest` | sqlite | +| `simplex-chat-libs-macos-x86_64.zip` | `build-macos`, `macos-15-intel` | sqlite | +| `simplex-chat-libs-windows-x86_64.zip` | `build-windows` | sqlite | + +Zip content: directory `libs/` with the top-level files of `apps/multiplatform/common/src/commonMain/cpp/desktop/libs/-/`. +Windows zip: also `libs/libsimplex.lib`. +Asset names: as built by `download-libs.js` (line 123). +Checksums: `digest` (sha256) of each asset in the GitHub release API. + +## 3. `build.yml` + +### 3.1. `maybe-release` + +The release is created before the build jobs; each build job uploads its assets, as for `v*` tags. + +``` +outputs libs_tag: tag of the created library release, empty otherwise +Clone project condition: push event; fetch-depth: 2 +``` + +Steps after "Create release": + +| Step | Condition | Action | +|---|---|---| +| Check version change | ref `master` or `stable` | output `version` when `version:` of `simplex-chat.cabal` differs from `HEAD^1:simplex-chat.cabal` | +| Clone libs repo | `version` set | checkout of simplex-chat-libs to `libs-repo`, token `NODEJS_REPO_TOKEN` | +| Commit and tag libs release | `version` set | commit and tag below; `git push --atomic origin HEAD `; output `tag` | +| Create libs release | `version` set | `softprops/action-gh-release@v2`, repository simplex-chat-libs, `tag_name` = `tag`, without files | + +Commit in simplex-chat-libs: + +``` +file core-release +content version: + commit: +author SimpleX Chat +message core- +tag core-, on this commit +``` + +`NODEJS_REPO_TOKEN`: a token of the build account; releases are attributed to the token owner. +Secrets comment: above `maybe-release`. + +A failure of `maybe-release` skips the build jobs, as for `v*` tags. + +### 3.2. Concurrency + +Workflow-level `concurrency`: removed. + +Job-level `concurrency` in `build-linux`, `build-macos`, `build-windows`: + +``` +group ${{ github.workflow }}-${{ github.ref }}---${{ needs.maybe-release.outputs.libs_tag }} +cancel-in-progress not a v* tag and libs_tag empty +``` + +Library release runs are never cancelled; other runs are cancelled by the next push to the same ref. + +### 3.3. Library steps + +| Job | Entries | Position | Build | +|---|---|---|---| +| `build-linux` | 22.04 x86_64 and 22.04 aarch64, GHC 9.6.3 (matrix key `libs: true`) | after "Build CLI" | `SKIP_VLC=1 scripts/desktop/build-lib-linux.sh` | +| `build-macos` | both (matrix key `arch`) | after "Run tests" | `SKIP_VLC=1 scripts/desktop/build-lib-mac.sh` | +| `build-windows` | `windows-latest` | after "Build CLI" | `SKIP_VLC=1 scripts/desktop/build-lib-windows.sh`; `dlltool -d libsimplex.dll.def -l libsimplex.lib -D libsimplex.dll` | + +Each build step: staging in `release-libs//libs/`, then `zip -r` to `release-libs/.zip`. +Linux steps run in the build container. +Windows: `zip` added to the MSYS2 `install` list. + +Upload step, after the build steps of the job: + +``` +action softprops/action-gh-release@v2 +repository ${{ github.repository }}-libs +tag_name needs.maybe-release.outputs.libs_tag +files release-libs/*.zip +token secrets.NODEJS_REPO_TOKEN +``` + +Conditions: + +| Job | Build | Upload | +|---|---|---| +| `build-linux` | `libs_tag` set, `matrix.libs` | `libs_tag` set, `matrix.libs` | +| `build-macos` | `!cancelled()`, `libs_tag` set, CLI build succeeded | `!cancelled()`, library build succeeded | +| `build-windows` | `libs_tag` set | `libs_tag` set | + +A failed job leaves its assets absent; the other jobs upload theirs. +A re-run of a failed job uploads its assets to the same release; an asset of the same name is replaced. + +### 3.4. `build-linux-postgres` + +Edits of the existing job: + +``` +if needs.maybe-release.outputs.libs_tag, replaces the v* tag condition +Copy libs from container + files copied to postgres-libs/libs/ + zip -r simplex-chat-libs-linux-x86_64-postgres.zip libs, in postgres-libs +Upload postgres library + replaces the artifact upload + softprops/action-gh-release@v2, files postgres-libs/*.zip, other inputs as in 3.3 +``` + +## 4. Scripts: `SKIP_VLC` + +With `SKIP_VLC` set, the VLC download is skipped. +Unset: unchanged. + +| File | Line | After | +|---|---|---| +| `scripts/desktop/build-lib-linux.sh` | 56 | `[ -n "$SKIP_VLC" ] \|\| scripts/desktop/prepare-vlc-linux.sh` | +| `scripts/desktop/build-lib-mac.sh` | 154 | `[ -n "$SKIP_VLC" ] \|\| scripts/desktop/prepare-vlc-mac.sh` | +| `scripts/desktop/build-lib-windows.sh` | 64 | `[ -n "$SKIP_VLC" ] \|\| scripts/desktop/prepare-vlc-windows.sh` | + +## 5. `build.yml`: removed job + +`release-nodejs-libs`, with its header comment. + +## 6. Node.js package + +``` +download-libs.js RELEASE_TAG = 'core-', set in the Node.js release commit, once release core- exists +package.json version: Node.js release cycle +release commits RELEASE_TAG removed from their edits (author build@simplex.chat) +``` + +This change: `RELEASE_TAG` unchanged, `'v7.1.0-beta.6'`. + +Platforms: `linux-aarch64` added by the assets in section 2; `download-libs.js` maps `arm64` to `aarch64` (lines 46-50). +Postgres: Linux x86_64 only (lines 19-22). + +## 7. Precompiled add-on (`ep/node-add-on`) + +Add-on releases: separate from library releases, triggered by changes of the Node.js package version, designed in `ep/node-add-on`. + +Effects of this plan on `ep/node-add-on`: + +``` +release-nodejs-libs removed; its add-on upload edits move to the add-on release +RELEASE_TAG library release core-; add-on assets in a separate release +SHA256 library values from the asset digests of release core- +``` + +## 8. `build.yml` fix + +Line 531: `matrix.opnessl_dir` → `matrix.openssl_dir`. + +## 9. Verification + +1. Branch run of `build.yml` with temporary edits: `push` trigger and "Check version change" condition with the branch, tag `core-test-`, cabal version increment in the head commit of the push. +2. `npm install` of the Node.js package with `RELEASE_TAG = 'core-test-'` on Linux x86_64, Linux aarch64, macOS aarch64, macOS x86_64, Windows x86_64. +3. Postgres: `SIMPLEX_BACKEND=postgres npm install` on Linux x86_64. +4. Release `core-test-` and its tag deleted after verification. diff --git a/plans/2026-10-11-markdown-file-viewer.md b/plans/2026-10-11-markdown-file-viewer.md new file mode 100644 index 0000000000..c5152ea485 --- /dev/null +++ b/plans/2026-10-11-markdown-file-viewer.md @@ -0,0 +1,319 @@ +# Markdown file viewer: implementation plan + +Markdown files from chats are viewed in the Android, desktop and iOS apps, formatted or as text. + +## 1. Files that qualify + +By name and size only: + +``` +name file.fileName ends with .md or .markdown, case-insensitive +size file.fileSize <= MAX_MARKDOWN_VIEW_SIZE +``` + +``` +MAX_MARKDOWN_VIEW_SIZE 1_048_576 +Kotlin views/chat/item/MarkdownFileView.kt, private +iOS Shared/Views/Chat/ChatItem/MarkdownFileView.swift, private +``` + +Function, in each viewer file: + +``` +Kotlin fun isMarkdownFile(file: CIFile): Boolean +iOS func isMarkdownFile(_ file: CIFile) -> Bool +``` + +Other files, and Markdown files over the limit: tap actions unchanged. + +## 2. Tap and long press + +Tap on a qualifying file, every platform: + +``` +file status action +received or sent, file loaded viewer +desktop with a linked mobile loadRemoteFile, then viewer +RcvInvitation, RcvAborted reception started; viewer shown on completion, in the same place +other statuses as now: waiting alerts, error alerts +``` + +Long press: the existing Save item of the item menu (`ChatItemView.kt`, `ChatView.swift`). + +Places: + +``` +Kotlin CIFileView.kt, onClick and fileAction +iOS CIFileView.swift, fileAction +``` + +- Android: "Open with" of `SaveOrOpenFileMenu` is not shown for qualifying files; "Share" in the item menu stays. +- Present locally: `getLoadedFilePath(file)` (Kotlin), `getLoadedFileSource(file)` (iOS), as for other files. + +## 3. Open on completion + +In master (#7700), for images, voice messages, video and files. The viewer adds only the Markdown case to the file tap action. + +A tap that starts reception registers the file. On completion the tap action of the received file is performed: + +``` +image full screen view +voice playback +video playback; locally encrypted: decrypted first; chat list preview: full screen player +file Markdown: viewer + other: share sheet (iOS), open/save menu (Android), save dialog (desktop) +``` + +Kotlin, chat model: + +``` +fileToOpen file ID of the last tapped file + inChatList: tap in a chat list preview +openWhenReceived(file, inChatList) sets fileToOpen +receivedFileToOpen(file, inChatList) false unless fileToOpen has file.fileId and inChatList, and the file is loaded + then fileToOpen cleared + chat view: true + chat list preview: true on desktop, or when no chat is open +``` + +Item views: `OpenWhenReceivedEffect(file, inChatList, open)` (`CIFileView.kt`), on a change of `file.loaded`; in `CIImageView.kt`, `CIVoiceView.kt`, `CIVideoView.kt`, `CIFileView.kt`. + +iOS, chat model: + +``` +fileToOpen file ID of the last tapped file + chat ID at the tap; nil in the chat list + open callback of the tapped view +openWhenReceived(file, open) sets fileToOpen +receivedFileToOpen(file) fileToOpen has file.fileId, and the open chat is the chat at the tap +openReceivedFile(file) receivedFileToOpen rechecked; fileToOpen cleared; open(file) +``` + +Event `rcvFileComplete` (`SimpleXAPI.swift`): for the active user, when `receivedFileToOpen(file)`: `openReceivedFile(file)` on the main actor. + +`fileToOpen` is replaced by a later tap. + +## 4. Reading in memory + +The file is read and decoded off the main thread, before the viewer is shown. + +``` +Kotlin withLongRunningApi + encrypted: readCryptoFile(path, cryptoArgs) (CryptoFile.kt:36) + plain: File(path).readBytes() + text: UTF-8 CharsetDecoder, REPLACE with "?" + parsed: parseMarkdownFlow (renderer), once, before the viewer is shown +iOS Task + getFileData(path, cryptoArgs) (SimpleXChat/ImageUtils.swift:41) + text: Unicode.UTF8.ForwardParser, "?" for each invalid byte + html: MarkdownParser with an .html modifier, raw HTML escaped +``` + +Text decoding, as the core safeDecodeUtf8 (simplexmq Util.hs:383-386): + +``` +invalid UTF-8 "?" for each invalid byte +C0 controls U+0000-U+001F replaced with "?", except tab, line feed, carriage return +line endings CRLF and CR are line breaks: Kotlin text.lines(); iOS: replaced with LF before parsing +``` + +Limits, checked before rendering; over a limit: text view only, the switch hidden: + +``` +line length 15 000 characters +nesting Kotlin: 32 levels of the parsed tree; iOS: 16 levels estimated from line prefixes +link nesting iOS: 4 open square brackets, counted until an empty line +table rows 200 +parse error Kotlin: text view only +``` + +Text view: lines longer than 15 000 characters are split. + +- `readCryptoFile`: core `chatReadFile` (`Mobile/File.hs:87`); the file is decrypted by `CF.readFile` into memory. +- Bytes, text and HTML are held only in memory, in the viewer's state. +- Read error: alert with the error text; the viewer is not shown. +- File absent: "File not found" alert (Kotlin); no action (iOS), as for other files. + +## 5. Viewer + +``` +presentation Android: full screen, like the image viewer + desktop: over the conversation + iOS: sheet +toolbar back (Kotlin) + switch between the formatted and the text view + save +title file name +preference view chosen last; default formatted +``` + +### 5.1. Kotlin + +Renderer: `com.mikepenz:multiplatform-markdown-renderer` and `-m2`, 0.27.0 → 0.34.0 (`common/build.gradle.kts:80-81`). + +``` +0.34.0 dependencies kotlin-stdlib 2.1.20, org.jetbrains:markdown 0.7.3, kotlinx-collections-immutable 0.3.8 +compose compileOnly; built with Compose 1.8.0, the app uses 1.8.2 +app Kotlin 2.1.20, Compose Multiplatform 1.8.2 +added table component, LazyMarkdownSuccess, success lambda of Markdown() +``` + +New file `views/chat/item/MarkdownFileView.kt`: + +``` +isMarkdownFile(file) +showMarkdownFile(file) loadRemoteFile if needed, read, ModalManager.center.showModal +MarkdownFileView formatted or text view by appPrefs.markdownFileTextView +``` + +Formatted view: + +``` +renderer m2 Markdown(state), GFM flavour, the parsed state from showMarkdownFile +styles documentMarkdownStyle(h1) (OperatorView.kt), shared with the operator conditions + h1: typography h2, 26 sp, bold +lazy success lambda: LazyColumnWithScrollBar, title item, one item per top-level node: MarkdownElement in a Column +links LocalUriHandler: markdownFileUriHandler +code spans annotator: source text between the backticks; inlineCode style, inlineCodeBackground +images data:image URIs: DataImageTransformer, decoded off the main thread by decodeBase64Image + other image links: the annotator appends the source text; nothing is fetched +tables default table component +``` + +Links, classified by the core markdown parser (`parseToMarkdown`), as message links: + +``` +SimplexLink openVerifiedSimplexUri +Uri, http or https openBrowserAlert +other showInvalidLinkAlert +``` + +Displayed link targets (`displayedUri`, Kotlin and iOS; also in message link alerts and the external-link alert): + +``` +format characters (Cf) [U+XXXX] +non-ASCII in the authority [U+XXXX] +HTML not parsed (parseHtml = false) +``` + +Images: + +``` +decode decodeBase64Image(base64, window width): power-of-two subsampling, both sides kept at or above the window width + Android: decodeSampledBitmapFromByteArray, 4320 px per side + desktop: decodeBoundedBufferedImage, 16384 px per side, 4320 × 4320 decoded pixels +budget 2160 × 4320 decoded pixels per document; images over the budget are not shown +cache per document, in the transformer; the desktop base64BitmapCache is not used +``` + +Text view: `LazyColumnWithScrollBar`, title item, one monospace `Text` per line, inside `SelectionContainer`; format characters shown as `[U+XXXX]` markers. + +Bidi controls (U+061C, U+200E, U+200F, U+202A-U+202E, U+2066-U+2069): warning under the title, both views. + +Toolbar end buttons: + +``` +switch ic_code in the formatted view, ic_article in the text view; sets appPrefs.markdownFileTextView; hidden over the limits +save ic_download; rememberFileChooserLauncher; copyBytesToFile(ByteArrayInputStream(bytes)) (Files.kt:65) +``` + +`OperatorView.kt`: + +``` +markdownColor(linkText = primary) markdownColor(); link colour moved to the link text style (linkText deprecated in 0.34.0) +ConditionsMarkdown styles moved to documentMarkdownStyle(h1): DocumentMarkdownStyle (colors, typography, components, padding) + h1: body1 by default, as in the conditions +``` + +`SimpleXAPI.kt`: `markdownFileTextView = mkBoolPreference(SHARED_PREFS_MARKDOWN_FILE_TEXT_VIEW, false)`. + +Centre placement on desktop: modals are drawn over the open conversation. + +``` +ModalView.kt showCustomModal: chatId is not changed +App.kt CenterPartOfScreen: ModalManager.center.showInView() after ChatView +ChatListNavLinkView.kt "Set contact name": back button on desktop +``` + +`moko-resources/base/strings.xml`: + +``` +markdown_formatted_view Formatted view +markdown_text_view Text view +markdown_file_bidi_warning This file has text direction characters. The text can be shown in a different order than it is written. +``` + +### 5.2. iOS + +New file `Shared/Views/Chat/ChatItem/MarkdownFileView.swift`, added to the SimpleX target in `project.pbxproj`. + +``` +isMarkdownFile(file) +showMarkdownFile(file) read, parse, showAppSheet +MarkdownFileView NavigationView; toolbar: switch, save +``` + +``` +formatted ConditionsWebView(html:openLink:), Ink 0.6.0, raw HTML escaped by an .html modifier +text ScrollView, LazyVStack, one monospace Text per line, text selection, format characters as [U+XXXX] +switch @AppStorage(DEFAULT_MARKDOWN_FILE_TEXT_VIEW); hidden over the limits +save saveCryptoFile(fileSource) (CIFileView.swift) +close swipe down +links openMarkdownFileLink: parseSimpleXMarkdown; simplexLink: appOpenUrl; uri, http or https: openBrowserAlert; other: showInvalidLinkAlert +``` + +`SettingsView.swift`: `DEFAULT_MARKDOWN_FILE_TEXT_VIEW = "markdownFileTextView"`, default false. + +`ConditionsWebView.swift`, shared with the operator conditions: + +``` +configuration allowsContentJavaScript = false; websiteDataStore = .nonPersistent() +head Content-Security-Policy: default-src 'none'; style-src 'nonce-'; img-src data:; base-uri 'none'; form-action 'none' +style the app's style block with the nonce; table, th, td borders; pre overflow-x auto; img max-width 100% +navigation linkActivated: openLink (openExternalLink by default) + other navigation: allowed for about: URLs only +``` + +- Ink 0.6.0 applies `.html` modifiers to block and inline HTML fragments (`HTMLConvertible.swift`); the viewer's modifier escapes `&`, `<`, `>`; code spans and blocks are escaped by Ink itself. + +Images, an `.images` modifier (`imagesWithinBudget`): + +``` +data:image, base64 shown as , rebuilt from the source only + 4320 px per side; 2160 × 4320 pixels per document; size read by UIImage(data:) +over a limit not shown +other data URIs not shown +other image links the source text; nothing is fetched +``` + +## 6. Size limit + +1 MB. +4 MB: formatted views render lazily (Kotlin: one lazy item per top-level node; iOS: WKWebView); text views render one lazy item per line. +A single top-level block (one table, code block or paragraph) is still laid out at once. + +## 7. Checks + +1. Plain and encrypted files: formatted and text views on Android, desktop and iOS. +2. Desktop linked to a mobile: file loaded from the mobile, then shown. +3. File over 1 MB: tap action as before. +4. File not received: one tap; viewer shown on completion in the same chat; not shown after leaving the chat. +5. Same for images, voice messages, video and other files, each with its tap action. +6. Tap in a chat list preview: opened on completion while the chat list is shown; Android, iOS: not opened after a chat is opened; desktop: opened while a chat is open. +7. Not opened after a reception error, cancel or abort. Kotlin: not opened after the item scrolled out of view. +8. Invalid UTF-8 and control characters: shown as "?". +9. Lines over 15 000 characters, 33 tree levels (Kotlin), 17 nesting levels (iOS), 5 nested square brackets (iOS), 201 table rows: text view only, long lines split. +10. Bidi controls: warning; format characters as [U+XXXX] in the text view. +11. Data images: decoded to the window width (Kotlin); images over 4320 px per side (Android, iOS) or beyond 2160 × 4320 pixels per document not shown. +12. Links: SimpleX links to the connect flow; http(s) links to the "Open web link?" alert; other schemes and relative links: "Invalid link"; targets with invisible or non-ASCII host characters shown with [U+XXXX]. +13. View switch kept after closing the viewer and after a restart. +14. Save: the file at the chosen destination equals the original. +15. Link inside backticks: shown as code. +16. Remote image: not loaded, the source text shown; data URI image: shown. +17. iOS inline HTML with ` diff --git a/website/src/_includes/layouts/doc.html b/website/src/_includes/layouts/doc.html index e148867f17..f82c92e4d6 100644 --- a/website/src/_includes/layouts/doc.html +++ b/website/src/_includes/layouts/doc.html @@ -16,6 +16,7 @@ + {% include "markdown-alternate.html" %} diff --git a/website/src/_includes/layouts/jobs.html b/website/src/_includes/layouts/jobs.html index 5aa3ff286b..5fb97b3f9a 100644 --- a/website/src/_includes/layouts/jobs.html +++ b/website/src/_includes/layouts/jobs.html @@ -15,6 +15,7 @@ + {% include "markdown-alternate.html" %} diff --git a/website/src/_includes/layouts/main.html b/website/src/_includes/layouts/main.html index d51c934446..fe39c7011a 100644 --- a/website/src/_includes/layouts/main.html +++ b/website/src/_includes/layouts/main.html @@ -26,6 +26,7 @@ + {% include "markdown-alternate.html" %} diff --git a/website/src/_includes/layouts/page.html b/website/src/_includes/layouts/page.html index c198f20d6a..4db8466b35 100644 --- a/website/src/_includes/layouts/page.html +++ b/website/src/_includes/layouts/page.html @@ -22,6 +22,7 @@ + {% include "markdown-alternate.html" %} diff --git a/website/src/_includes/layouts/privacy.html b/website/src/_includes/layouts/privacy.html index d226459dc1..00c506bcc9 100644 --- a/website/src/_includes/layouts/privacy.html +++ b/website/src/_includes/layouts/privacy.html @@ -24,6 +24,7 @@ {% endif %} + {% include "markdown-alternate.html" %} diff --git a/website/src/_includes/layouts/token.html b/website/src/_includes/layouts/token.html index fa884271e0..be63a23413 100644 --- a/website/src/_includes/layouts/token.html +++ b/website/src/_includes/layouts/token.html @@ -20,6 +20,7 @@ + {% include "markdown-alternate.html" %} @@ -585,7 +586,7 @@ } else { content.innerHTML += `

- Install MetaMask wallet + Install MetaMask wallet

`; } break; @@ -619,7 +620,7 @@ html += renderAttributes(meta.attributes, appState.openedTraits); html += '

'; html += `${appState.totalMinted} NFTs minted. `; - html += `Explorer ${linkSvg}`; + html += `Explorer ${linkSvg}`; html += '

'; } html += ``; @@ -673,7 +674,7 @@ if (nextMeta.description) html += `

${nextMeta.description}

`; html += '

'; html += `${appState.totalMinted} NFTs minted. `; - html += `Explorer ${linkSvg}`; + html += `Explorer ${linkSvg}`; html += '

'; if (withAttributes) { html += renderAttributes(nextMeta.attributes, appState.nextOpenedTraits); diff --git a/website/src/_includes/llms/agents.md b/website/src/_includes/llms/agents.md new file mode 100644 index 0000000000..ba95d9715e --- /dev/null +++ b/website/src/_includes/llms/agents.md @@ -0,0 +1,157 @@ +# SimpleX for AI agents and developers + +SimpleX is a messaging network without user identifiers. A program connects to it the same way a person does: it holds its own keys, creates addresses it can share and delete, and needs no phone number, platform account or API key. The network does not distinguish a person from an agent, and no platform can suspend an agent's identity or see who it talks to. + +What you can build: + +- an assistant that its owner reaches privately from the SimpleX Chat app, on any device; +- a support or search bot for many users, with an LLM or without one; +- communication between agents with no platform in the middle; +- moderation, broadcast and bridge bots for groups and channels. + +## Make a bot in five minutes + +### Node.js + +```sh +npm i simplex-chat +``` + +```javascript +(async () => { + const {bot} = await import("simplex-chat") + await bot.run({ + profile: {displayName: "Squaring bot example", fullName: ""}, + dbOpts: {type: "sqlite", filePrefix: "./squaring_bot"}, + options: { + addressSettings: {welcomeMessage: "Send a number, I will square it."}, + }, + onMessage: async (ci, content, chat) => { + const n = +content.text + const reply = typeof n === "number" && !isNaN(n) + ? `${n} * ${n} = ${n * n}` + : `this is not a number` + await chat.apiSendTextReply(ci, reply) + } + }) +})() +``` + +On start, the bot creates its SimpleX address and prints it (`Bot address: ...`). Open this address in the SimpleX Chat app to talk to the bot. Library: [simplex-chat on npm](https://www.npmjs.com/package/simplex-chat), [source and examples](https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-nodejs). + +### Python (3.11+) + +```sh +pip install simplex-chat +``` + +```python +import re +from simplex_chat import Bot, BotProfile, Message, SqliteDb, TextMessage + +bot = Bot( + profile=BotProfile(display_name="Squaring bot"), + db=SqliteDb(file_prefix="./squaring_bot"), + welcome="Send me a number, I'll square it.", +) + +@bot.on_message(content_type="text", text=re.compile(r"^-?\d+(\.\d+)?$")) +async def square(msg: TextMessage) -> None: + n = float(msg.text or "0") + await msg.reply(f"{n} * {n} = {n * n}") + +@bot.on_message(content_type="text") +async def fallback(msg: Message) -> None: + await msg.reply("Send me a number, like 7 or 3.14.") + +if __name__ == "__main__": + bot.run() +``` + +The bot logs its address on start. Library: [simplex-chat on PyPI](https://pypi.org/project/simplex-chat/), [source and examples](https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-python). + +## Use the terminal client as an API server, from any language + +Install the terminal client on Linux or macOS: + +```sh +curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash +``` + +Run it as a local WebSocket server: + +```sh +simplex-chat -p 5225 +``` + +Your program connects to `ws://localhost:5225` and sends JSON commands, for example to create a long-term address: + +```json +{"corrId": "1", "cmd": "/address"} +``` + +Responses carry the same `corrId`; chat events, such as received messages, arrive as separate messages. The command strings are the same as in the app's chat console. The WebSocket API has no authentication and binds to localhost: run your program on the same machine, or put a TLS proxy with authentication in front of it. + +- [Bot API guide](https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/bots/README.md) +- [Commands](https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/bots/api/COMMANDS.md) +- [Events](https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/bots/api/EVENTS.md) +- [Types](https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/bots/api/TYPES.md) +- [Terminal client guide](https://simplex.chat/docs/cli.md) + +## Build the terminal client from source + +With Docker, on Linux: + +```sh +git clone https://github.com/simplex-chat/simplex-chat.git +cd simplex-chat +git checkout stable +DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . +``` + +With GHC 9.6.3 and cabal 3.10.1.0 installed via [GHCup](https://www.haskell.org/ghcup/), on Linux: + +```sh +git clone https://github.com/simplex-chat/simplex-chat.git +cd simplex-chat +git checkout stable +apt-get update && apt-get install -y build-essential libgmp3-dev zlib1g-dev +cp scripts/cabal.project.local.linux cabal.project.local +cabal update +cabal install simplex-chat +``` + +On macOS, install OpenSSL with `brew install openssl@3.0` and use `scripts/cabal.project.local.mac` instead. Full instructions: [terminal client guide](https://simplex.chat/docs/cli.md). + +## OpenClaw + +A community plugin connects [OpenClaw](https://github.com/openclaw/openclaw) to SimpleX through a locally running terminal client, so you can talk to your OpenClaw assistant from the SimpleX Chat app: + +```sh +openclaw plugins install @dangoldbj/openclaw-simplex +openclaw plugins enable openclaw-simplex +``` + +Source: [github.com/dangoldbj/openclaw-simplex](https://github.com/dangoldbj/openclaw-simplex). + +## Run your own servers + +- [SMP messaging server](https://simplex.chat/docs/server.md) +- [XFTP file server](https://simplex.chat/docs/xftp-server.md) + +## Protocol specifications + +- [SimpleX network overview](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/overview-tjr.md) +- [SimpleX Messaging Protocol](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/simplex-messaging.md) +- [Agent protocol](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/agent-protocol.md) +- [Post-quantum double ratchet](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/pqdr.md) +- [Security model](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/security.md) +- [XFTP file transfer protocol](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/xftp.md) +- [Chat protocol](https://simplex.chat/docs/protocol/simplex-chat.md) + +## Ask the SimpleX team + +In the SimpleX Chat app, open the "Ask SimpleX Team" chat, or connect via [simplex.chat/contact](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). A support bot answers first: + +- send `/team` to reach the team – not a bot; the team replies within 24 hours, or 48 hours at weekends; +- send `/grok` for an instant answer from an AI assistant. diff --git a/website/src/_includes/llms/answers.md b/website/src/_includes/llms/answers.md new file mode 100644 index 0000000000..7a5404b498 --- /dev/null +++ b/website/src/_includes/llms/answers.md @@ -0,0 +1,73 @@ +# SimpleX: answers to common questions + +Short answers with links to the full documentation. Numbers are in [key facts](https://simplex.chat/llms/facts.md). + +## What is SimpleX, in one sentence? + +SimpleX is the first messaging network without user identifiers of any kind – no phone numbers, usernames or random IDs – so nobody, including its servers, can see who talks to whom. + +## What is the difference between SimpleX network and SimpleX Chat? + +SimpleX network is the open protocol and the servers that relay messages. SimpleX Chat is the free, open-source app (iOS, Android, Linux, macOS, Windows and a terminal client) that uses the network, and also the company that builds both. + +## How does SimpleX deliver messages without user identifiers? + +Each conversation uses its own pair of one-way message queues, one for each direction, usually on different servers. A queue has a random address that is used only for that conversation. Contacts and groups are stored only on users' devices. To connect, people share a one-time link or a QR code, which also carries the keys for end-to-end encryption, so servers cannot substitute them. Details: [How SimpleX works](https://simplex.chat/docs/simplex.md), [protocol overview](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/overview-tjr.md). + +## What can servers and network observers see? + +Servers relay end-to-end encrypted messages and delete them once they are delivered. A server sees the random addresses of the queues it hosts and the times when messages arrive and are received. All messages are padded to the same size. With private message routing, the sender chooses the forwarding server and the recipient chooses the destination server, so neither side can observe the other's IP address. A server cannot see who talks to whom, cannot read messages, and cannot add or change them undetectably. Details: [security model](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/security.md). + +## How strong is the encryption? + +Messages are end-to-end encrypted with a double ratchet that gives forward secrecy and recovery after a key compromise. A post-quantum key exchange (sntrup761) is added on every ratchet step and is on by default in direct conversations. An additional encryption layer between servers and devices prevents correlating sent and received traffic. Details: [post-quantum double ratchet](https://raw.githubusercontent.com/simplex-chat/simplexmq/stable/protocol/pqdr.md). + +## Why are public channels not end-to-end encrypted? + +By design. No system can provide encrypted messages, scalable broadcast and private participation at the same time. Anyone can join a channel through its public link, so encrypting its content would protect nothing; protecting who participates does. SimpleX channels make participation private: relays see the content, but not subscribers' identities or network addresses, and participation in different channels cannot be linked. Details: [SimpleX Channels](https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md), [channels whitepaper](https://simplex.chat/docs/protocol/channels-overview.md). + +## Is SimpleX audited? + +Yes. Trail of Bits assessed the implementation in October 2022 and reviewed the cryptographic design in July 2024. A third audit was completed in 2026 and will be published. Reports: [2022](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SimpleX_Chat_Final_Report_11_03_2022.pdf), [2024](https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SimpleX_Design_Review_2024_Summary_Report_12_08_2024.pdf). + +## What are SimpleX's limitations? + +- Post-quantum key exchange is used in direct conversations, not yet in groups. +- An observer who can watch the traffic of both users can confirm that they communicate. +- Resolving a public name reveals interest in that name to one resolver server, but not who asked. + +## How does SimpleX compare with Signal, Session, Matrix, Briar and others? + +Every other messaging network assigns users an identifier: Signal and WhatsApp use phone numbers, Matrix uses `@user:server` IDs, and Session, Briar, Cwtch and Nostr identify users by long-term public keys or onion addresses. These identifiers let the network, or anyone observing it, link a user's conversations. SimpleX has none. A side-by-side comparison of encryption properties is on the [messaging page](https://simplex.chat/messaging/#messengers-comparison), and a technical comparison with peer-to-peer protocols is in [How SimpleX works](https://simplex.chat/docs/simplex.md). + +## Can people find or contact me without my consent? + +No. Nobody can contact you unless you share a one-time link or your address. An address is optional and can be changed or deleted without losing contacts. + +## Who controls the network? + +Nobody alone. Anyone can run servers. SimpleX Chat and Flux are preset in the app, and when both are enabled the app uses servers of both operators in each conversation. The SimpleX Network Consortium, an agreement between the non-profit SimpleX Network Foundation and SimpleX Chat, licenses the protocol to the foundation permanently, so it stays available regardless of who owns the company. Details: [blog](https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md). + +## Is SimpleX free and open source? + +Yes. The apps are free, and the code is open source under AGPL-3.0: [github.com/simplex-chat](https://github.com/simplex-chat). + +## How will SimpleX Chat make money? + +Private messaging stays free. Channels and businesses will pay for public names, business messaging and servers for big channels. Users have already donated over $650,000. Details: [Wefunder memo](https://simplex.chat/wefunder.md). + +## What are SimpleX public names? + +Optional, human-readable names for channels and businesses, such as `#example` or `@example.simplex`, registered on a public blockchain and controlled only by their owner's key. Names do not identify users and are not needed to use the network. Sale starts on 12 December 2026. Details: [public names whitepaper](https://simplex.chat/docs/protocol/names-overview.md). + +## How can AI agents and developers use SimpleX? + +An agent connects to the network like a person: it holds its own keys and addresses, and no platform account is needed. See [SimpleX for AI agents and developers](https://simplex.chat/llms/agents.md). + +## How can I invest in SimpleX Chat? + +SimpleX Chat runs an equity crowdfunding offering on Wefunder: [wefunder.com/simplex.chat](https://wefunder.com/simplex.chat). The memo is reproduced at [simplex.chat/wefunder.md](https://simplex.chat/wefunder.md). Investing involves a high degree of risk, including the possible loss of your investment. + +## How do I contact the SimpleX team? + +In the SimpleX Chat app, open the "Ask SimpleX Team" chat, or connect via [simplex.chat/contact](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). A support bot answers first; send `/team` to reach the team. diff --git a/website/src/_includes/llms/facts.md b/website/src/_includes/llms/facts.md new file mode 100644 index 0000000000..e03c05ab7a --- /dev/null +++ b/website/src/_includes/llms/facts.md @@ -0,0 +1,12 @@ +# SimpleX: key facts + +Facts about SimpleX network and SimpleX Chat, each with the date it was stated and its source. Updated {{ llms_facts.updated }}. Machine-readable version: [simplex.chat/llms/facts.json](https://simplex.chat/llms/facts.json). +{% for group in llms_facts.groups %} +## {{ group.title | safe }} + +| Fact | Value | As of | Source | +|---|---|---|---| +{% for item in group.facts -%} +| {{ item.fact | safe }} | {{ item.value | safe }} | {{ item.asOf | safe }} | {% for source in item.sources %}[{{ source.title | safe }}]({{ source.url }}){% if not loop.last %}, {% endif %}{% endfor %} | +{% endfor -%} +{% endfor %} diff --git a/website/src/_includes/llms/timeline.md b/website/src/_includes/llms/timeline.md new file mode 100644 index 0000000000..fabef57dfd --- /dev/null +++ b/website/src/_includes/llms/timeline.md @@ -0,0 +1,27 @@ +# SimpleX: timeline + +Key milestones, with links to the announcements. All posts: [SimpleX blog](https://simplex.chat/blog.md). + +| Date | Milestone | +|---|---| +| May 2021 | [SimpleX Chat prototype announced](https://simplex.chat/blog/20210512-simplex-chat-terminal-ui.md) | +| January 2022 | [SimpleX Chat v1](https://simplex.chat/blog/20220112-simplex-chat-v1-released.md) | +| March 2022 | [Mobile apps for iOS and Android](https://simplex.chat/blog/20220308-simplex-chat-mobile-apps.md) | +| November 2022 | [Implementation security assessment by Trail of Bits](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md) | +| March 2023 | [XFTP – SimpleX File Transfer Protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.md) | +| April 2023 | [Pre-seed funding from Village Global and angel investors](https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md) announced (closed in July 2022) | +| September 2023 | [Desktop app and group directory service](https://simplex.chat/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md) | +| March 2024 | [Post-quantum end-to-end encryption](https://simplex.chat/blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md) | +| April 2024 | [Post-quantum encryption on by default in direct conversations](https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md) | +| June 2024 | [Private message routing protects IP addresses](https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md) | +| August 2024 | [Investment from Jack Dorsey and Asymmetric Capital Partners](https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md) announced (closed in July 2023) | +| October 2024 | [Cryptographic design review by Trail of Bits](https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) | +| December 2024 | [Preset servers operated by Flux; business chats](https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.md) | +| January 2025 | [Large groups and privacy-preserving content moderation](https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md) | +| April 2026 | [SimpleX Channels and SimpleX Network Consortium](https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md) | +| July 2026 | [SimpleX public names in test](https://simplex.chat/blog/20260722-simplex-public-names.md) | +| August 2026 | [Equity crowdfunding launched on Wefunder](https://simplex.chat/blog/20260819-simplex-chat-crowdfunding.md) | +| September 2026 | [Supporter badges](https://simplex.chat/blog/20260919-simplex-supporter-badges.md) | +| 12 December 2026 | `.simplex` names sale starts ([simplex.domains](https://simplex.domains/)) | +| June 2027 | Planned: Community Credits for sustainable communities and servers ([roadmap](https://simplex.chat/index.md)) | +| December 2027 | Planned: tools to promote communities ([roadmap](https://simplex.chat/index.md)) | diff --git a/website/src/_includes/llms/wefunder.md b/website/src/_includes/llms/wefunder.md new file mode 100644 index 0000000000..4a55d9f126 --- /dev/null +++ b/website/src/_includes/llms/wefunder.md @@ -0,0 +1,194 @@ +# SimpleX Chat equity crowdfunding on Wefunder + +SimpleX Chat is conducting an equity crowdfunding offering under Regulation Crowdfunding through Wefunder Portal LLC: [wefunder.com/simplex.chat](https://wefunder.com/simplex.chat). The offering terms are on Wefunder, including its AI-readable profile: [wefunder.com/simplex.chat.ai.txt](https://wefunder.com/simplex.chat.ai.txt). + +This page reproduces the memo from the Wefunder page, for AI assistants that cannot read Wefunder. + +## Memo + +For most of human history, conversations were private by default, and the communities people built belonged to them. The internet made communication easier, but it also placed companies between people and the relationships they created. SimpleX exists to bring that privacy and ownership back to the people. + +### In short + +SimpleX is the first and the only messaging network without any user identifiers – no phone numbers or usernames, not even random numbers. No one, including the servers, can see who talks to whom. + +No paid marketing. Hundreds of community publications. Over 3 million downloads. About 600,000 monthly users. $650,000 user donations. Over 1,000 community servers. Three security audits by Trail of Bits, the last to be published. + +Why now? Accelerating surveillance and deplatforming, and the rise of AI agents may increase demand for identity-free messaging that no other network can provide. + +SimpleX is more than a messenger – it's an open platform others can build on. Developers already built moderation and AI bots, bridges, and other services. Every service may increase the value of the network and bring new people in. + +We plan to charge businesses and channels for public names, business messaging and paid servers beyond the free tier, while private messaging remains free. + +### Current perks for investors + +If you invest $500 or more, you will receive a free SimpleX public name for your channel or business – 8+ letters with $500+ investment, 7+ letters with $1,000+, and 6+ letters with $5,000+. + +The name will be registered for 5 years while early bird terms apply, and for 3 years after that. + +All investors receive free SimpleX supporter badges as perks. More details: [SimpleX Supporter Badges](https://simplex.chat/blog/20260919-simplex-supporter-badges.md). + +### Every other network can identify users + +For 150 years, all networks required user identifiers. From the telephone invented in the 19th century, to email services, social media, and messaging – using phone numbers, usernames, random IDs or public keys – before SimpleX Network, to be reachable, you had to be identifiable. + +#### Every other messenger knows who you talk to + +User identification gives networks power over users to surveil their private life, to sell their data, and to revoke access. Even encrypted platforms see who you talk to, when, and where. Pseudonyms don't help – your contacts are a fingerprint. + +And being identified puts your identity and data at risk of being used by criminals. + +#### Every other platform can censor users without due process + +Telegram blocked 44 million groups and channels in 2025 alone. YouTube creators lose channels and can't recover them even after winning in legal proceedings. + +Every platform that distributes content at scale identifies both the publisher and the audience – giving platform owners the power to censor public speech. + +Publishers and content creators build audiences on borrowed infrastructure. One policy change, and the audience that took years to build can be gone. + +The root cause of these problems is user identification. The solution is not better policies or stronger encryption with the same architecture – it is a different architecture, without user IDs. + +### We invented and built SimpleX Network without any user identifiers + +It uses no phone numbers, emails, or user accounts – to take power from the network and give it back to the users. The network runs on multiple independent operators – SimpleX Chat and Flux are preconfigured in the app, and anyone can run their own servers. + +And we built a free SimpleX Chat app, for mobile and desktop, that uses SimpleX Network to connect to other users. + +SimpleX Chat provides private messaging without any user IDs – it protects the users, because no one can see who they talk to. As one X user wrote: "As long as I don't invite anyone to a chat, nobody can find me – and that's the best part about it. That's how privacy works". And families tell us they use SimpleX Chat with their children, because they feel safe knowing no one can reach them. + +SimpleX Chat also enables public groups and channels, where owners fully control them, and nobody can take their work, because only the users hold the keys. + +Every conversation in SimpleX Chat has its own network address, removing the need for any user IDs on the network. There is no user data on the servers that could be breached or sold – all user data exists only on user devices. + +SimpleX software is open-source and based on open protocols. It was audited by Trail of Bits in 2022, 2024, and 2026 (will be published soon). + +### 600,000+ users – organic growth + +Over 600,000 people use SimpleX Chat every month – up roughly 70% in the last six months – and all of them found it without any paid marketing. Among them are security and cryptography experts – the hardest users to win over. + +200+ independent reviews, posts, and videos in 20+ languages ([simplex.chat/links](https://simplex.chat/links.md)). + +3 million people have downloaded the app. + +Users send over 20 million messages a day via pre-configured servers. + +Users have donated over $650,000 (including the donation from Vitalik Buterin), paying for something they could use for free. + +All of it was built on a total investment of just $1.7M. + +### SimpleX is a network, not just an app + +Not only the people who use SimpleX primarily for private messaging, but also four other kinds of participants came to the network: + +- Creators, with thousands of public groups and channels. +- Businesses: 50+ support customers over SimpleX. +- Developers: 19K+ GitHub stars, 1,400+ forks. +- Operators: ~1,000 servers run by volunteers at their own cost, plus Flux and StormyCloud. + +Each user group makes the network more valuable to the rest – more users attract creators and businesses, creators attract servers and developers, more content and network utility attracts more users – driving organic growth. + +### Channels accelerate SimpleX Network growth + +Messaging grows person-by-person: whole social graphs have to move together, which is slow against widely used networks, such as WhatsApp or Telegram. Channels accelerate it – one creator who brings a channel to SimpleX can bring thousands of subscribers, some of whom would then create their own channels. + +Users have created over 12,500 scalable channels in the five months since they shipped. 500+ public groups and channels are in the curated directory, 30% of them are active daily. Telegram channels tried to migrate in 2024/25, before SimpleX channels could scale. All that growth happened without in-app discovery of groups and channels – we are planning to release it this year, to further accelerate the growth. + +Each channel can also be published on the open web – like [simplex.chat/news](https://simplex.chat/news). Search engines index these pages, and anyone can read them in a browser without installing the SimpleX Chat app, so a channel can reach more people than use the app, and some of them may install it. + +### Developers already bet on SimpleX success + +Developers have already started building on SimpleX Network, with some projects defining themselves as SimpleX-first – they run all communications of their applications over the SimpleX Network. They built moderation, swap, and AI bots, bridges (Telegram/Matterbridge), a public server registry, and the OpenClaw plugin. To enable their work, we provide the JavaScript and Python libraries, a customer-support bot for businesses, and binaries to self-host servers. + +Every service developers build on SimpleX Network may increase its value, and bring new users to SimpleX Chat. In 2027, we are planning to release secure chat widgets and activities – to accelerate community innovation, by allowing custom UI for bots and services right in the conversation. + +### Why SimpleX cannot be copied + +No other communication system combines participation privacy, sovereign ownership, infrastructure independence, and scalable one-to-many delivery: + +| Property | Telegram | Nostr | Signal | Matrix | Mastodon | **SimpleX** | +|---|---|---|---|---|---|---| +| Content visible to operator | Yes | Yes | No | Configurable | Yes | **Yes** | +| Participant identity visible to operator | Yes | Yes | Yes | Yes | Yes | **No** | +| Channel identity independent of infrastructure | No | Yes | No | No | No | **Yes** | +| Sovereign ownership (no 3rd party can seize) | No | Yes | No | No | No | **Yes** | +| Programmable governance | No | No | No | No | No | **Planned** | +| Cryptographic content deniability | No | No | Yes | Yes | No | **Yes (default)** | +| Scalable one-to-many delivery | Yes | Yes | No | Limited | Yes | **Yes** | + +Of these networks, only SimpleX can protect participants' privacy, because it would require other networks to remove user identifiers – not adding a feature to an existing system but designing a different one. That is hard for three reasons, each stopping a different kind of competitor. + +Technically. Other networks rely on user IDs to route messages between users. Signal, Session, Matrix, and Nostr cannot simply drop IDs – they would have to rebuild from scratch. + +Economically. Large platforms monetize user identifiers: Telegram and Meta earn from ads and data, and that requires linking every action and connection to a single user ID. Removing IDs would destroy their revenue model. A large company is highly unlikely to do that. + +Cold start. A new company could build a no-ID network from scratch. But a network needs users, creators, businesses, developers, and servers, all at the same time. A newcomer would have to achieve it from zero, while competing with the SimpleX Network. + +### Why now: three trends that may grow SimpleX + +Growing surveillance of private life. Big centralized platforms are keen to scan private messages to train their AI models, supported by proposed laws. More people would move to use private messaging that keeps no record of who they talk to. + +Accelerating deplatforming. More and more creators are removed from centralized platforms, without any due process. An audience built over years can disappear with one policy change. Affected or at risk content creators would use decentralized networks where they own their work. + +AI agents acting for people. A growing number of AI agents are beginning to handle logins and money on behalf of people. If a platform controls an agent's identity, the platform can shut the agent down or turn it against the person it works for. User-controlled IDs are a basic security for the agentic Internet, not a preference. + +All three trends increase the demand for identity-free messaging. No other existing network can provide it. + +### Revenue plan: free for users, channels & businesses pay + +Private messaging should remain free for the users. While additional storage and transfer capacity may be paid, charging users for features would erode the trust that helps the network grow organically. Instead, we plan to earn from the infrastructure and services that channels and businesses need. On average, users donated ~$1.24 each per year, which shows they are willing to pay before any paid features exist. + +#### Public network names + +Most users want to be reachable only by people they choose. But businesses, creators, and publishers need to be found, so they need a public name they control. Even with a public name, the network cannot see who they talk to. + +SimpleX public names are globally unique names for a public channel or business, paid yearly and controlled by the owner's key, so no one can seize it. The public test is already released; sales are planned in 3–6 months. Pricing is based on name length. SimpleX Chat plans to earn approximately 80-90% of namespace revenue. At an average expected price of $50 a year (to be confirmed) for a name, 10,000 registered names would cover our current $360,000 annual operating cost. + +If you invest $500 or more in this community round, you would receive a name as a perk, registered for 5 years during early bird terms and for 3 years after that, when the names launch. + +#### Business messaging + +We are developing a web widget that would add encrypted chat to any website with one line of code, allowing visitors to talk to the business in their browser without installing anything. It is built for privacy-first businesses and business sectors where anonymity is legally required: whistleblowing, ethics, HR, security, legal, sensitive customers, sources, and tip lines. + +50+ businesses use SimpleX Chat for support, and they would be the first customers. The proof-of-concept is done; planned in 6–9 months from closing the round, with an expected price of $20–100/month per business. + +It grows the network as well. Someone who talks to a few businesses is already using SimpleX without having installed it, and a QR code scan would bring those conversations into the app. + +#### Paid servers for big channels + +Big public channels that have grown beyond the free tier would pay for the servers that deliver their messages. The proof-of-concept is ready, planned within 12 months of closing the community round, with an expected price of $20–50/month, depending on the channel size. We plan that server operators would earn up to 65% of the revenue, several times their cost, and SimpleX Chat earns the rest. + +### An open network others can build on + +The network is open, and it would help if other developers built their own apps on it – one app cannot reach every audience, and many apps may grow the network faster than we could alone. A bigger network may also bring more users and revenue for SimpleX Chat. + +The SimpleX Network Consortium, an agreement between a non-profit SimpleX Network Foundation and SimpleX Chat, prevents any single company from controlling the network. The protocol and the right to use the SimpleX mark for the network are licensed to the foundation permanently, so that operators and users can trust that the network protocols and software remain available regardless of who owns the company. + +SimpleX Chat business is protected too: the company owns the app, the brand, mobile application store distribution, preset relay infrastructure, and will retain its revenue share from the infrastructure payments, a position protected by its veto power over Consortium decisions. + +### The goal: the universal private transport layer + +Every network before messaging converged to one protocol – telephony, email, the web. Messaging is the exception, because each app's experience can't reduce to a shared protocol. Once we add custom user experiences in the chat, SimpleX might become the network messaging converges on. + +The planned revenue above is the first layer. The same network and protocol can deliver: private messages, public channels, businesses, and traffic between AI agents. The optional directory and payment system would create revenue for the server operators and SimpleX Chat. + +AI agents are the next layer, and developers already build AI bots. The network does not distinguish between a person and an agent. Agents need exactly what SimpleX provides: an identity no platform controls, and communication no one can observe. + +The same public names and payment system can be used by any service on the network, not only messaging or channels, so the revenue may grow with everything built on it. + +### Why this team wins + +We have worked on publishing, messaging, protocol design, blockchains, and cryptography. We have built large-scale systems and open-source software that millions of systems and projects rely on. + +We believe that what SimpleX Network achieved comes from listening to users' criticism. Unlike most technology startups, we announce our plans in advance, and change them before they become products, saving time and resources to get to product-market fit. + +We are also fortunate to have great advisors. Some of them are: Moritz Angermann (IOHK, Haskell compiler core contributor), Bruce McCarthy (Product Culture founder), Tom Ritter (Tor, Mozilla, an expert in secure protocol design), and Nick Rogers (ex-CPTO of Stream, an expert in agentic products). Three of them invested in this round. + +### Invest in SimpleX Chat + +By investing in SimpleX Chat, you benefit from the company growth. You also help build the first network that protects private life because it has no user identifiers – a design decision that cannot be retrofitted into any existing network. + +We are building a network that people own. Businesses, creators, and publishers keep the audiences and communities they build, and no company can take them away or unilaterally shut them down. All users can talk, read, and buy without being watched or tracked from one site to the next. None of this is new. Before the Internet, people's conversations were private, and the groups and audiences they built were their own, with no company in the middle. We lost that when we moved online, and we need it back – the future we set out in our [network philosophy](https://simplex.chat/why.md). + +We're building the network to support that future. We invite you to own a part of it: [wefunder.com/simplex.chat](https://wefunder.com/simplex.chat). + +This Regulation Crowdfunding offering is made available through Wefunder Portal LLC. This investment involves a high degree of risk, including the possible loss of your investment. diff --git a/website/src/_includes/markdown-alternate.html b/website/src/_includes/markdown-alternate.html new file mode 100644 index 0000000000..ad8301359c --- /dev/null +++ b/website/src/_includes/markdown-alternate.html @@ -0,0 +1,3 @@ +{% set markdownAlternateUrl = page | markdownAlternate %} +{% if markdownAlternateUrl %}{% endif %} + diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 6de0c372e3..efe079f441 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -67,12 +67,12 @@ @@ -126,7 +126,7 @@