diff --git a/.github/workflows/android-emu.yml b/.github/workflows/android-emu.yml index 1d195d653b..3bc26a5a50 100644 --- a/.github/workflows/android-emu.yml +++ b/.github/workflows/android-emu.yml @@ -1,12 +1,10 @@ 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 + description: Also run the Claude job, which spends the Claude subscription's usage limits type: boolean default: false backend: @@ -14,17 +12,450 @@ on: type: boolean default: false release: - description: Also test the latest release's onboarding + 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: + group: android-emu-${{ github.ref }} + cancel-in-progress: true + +env: + GHC_VER: 9.6.3 + CABAL_VER: 3.10.2.0 + JAVA_VER: 17 + CORE_PKG: android-core-arm64-v8a.tar.gz + BACKEND_PKG: desktop-linux-backend.tar.gz + APK: android-foss-arm64-v8a-debug.apk jobs: - placeholder: + plan: + name: choose jobs, find earlier builds runs-on: ubuntu-24.04 - timeout-minutes: 5 + 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: Nothing to run here - run: echo "This branch has only the placeholder of android-emu; start it on a branch with the full workflow." + - name: Choose jobs + id: jobs + env: + CLAUDE: ${{ inputs.claude }} + BACKEND: ${{ inputs.backend }} + RELEASE: ${{ inputs.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/android/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; + # the backend has desktop-linux's name and sources, so each workflow reuses the other's + - name: Artifact names + id: names + run: | + echo "core=android-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'flake.nix', 'flake.lock', 'scripts/nix/**', 'scripts/ci/android/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 Android arm64 + needs: plan + if: needs.plan.outputs.core-run == '' + runs-on: ubuntu-24.04 + timeout-minutes: 150 + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + filter: tree:0 + + - name: Show runner + run: | + lsb_release -ds + nproc + free -h + df -h / + + - name: Build core library + run: scripts/ci/android/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: Android app in the emulator + 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: 75 + 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" "$ANDROID_HOME/ndk" || true + + # the emulator's qemu needs libpulse0 and libegl1; the backend's avatars and checks need the rest + - name: Install tools + run: | + sudo apt-get update + sudo apt-get install -y libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core + dpkg-query -W libpulse0 libegl1 imagemagick 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/android/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 }} + + # the injected ABI builds only arm64-v8a, whose core the job has, and puts the APK under build/intermediates + - 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 -Pandroid.injected.build.abi=arm64-v8a :android:assembleFossDebug + mkdir -p "$RUNNER_TEMP/apk" + cp "android/build/intermediates/apk/foss/debug/$APK" "$RUNNER_TEMP/apk" + ls -la "$RUNNER_TEMP/apk" + unzip -l "$RUNNER_TEMP/apk/$APK" | grep -E ' lib/' + + - name: Upload app + if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: android-app + path: ${{ runner.temp }}/apk/${{ env.APK }} + compression-level: 0 + retention-days: 1 + if-no-files-found: error + + - name: Install emulator and system image + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: scripts/ci/android/install-sdk.sh "$RUNNER_TEMP/android-sdk" + + - name: Boot emulator + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: scripts/ci/android/emulator.sh start "$RUNNER_TEMP/smoke-test" + + # the test SMP server's addresses have the runner's 127.0.0.1, which adb reverse forwards from the emulator's + - name: Reach the test backend + id: reach + if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome == 'success' }} + run: scripts/ci/android/emulator.sh reverse 5223 + + - name: Run smoke test + if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome != 'failure' && steps.reach.outcome != 'failure' }} + env: + BACKEND: ${{ steps.reach.outcome }} + run: | + if [ "$BACKEND" = success ]; then + export NAMES_SERVER=$(cat "$RUNNER_TEMP/backend/names-server") + cases=backend + fi + scripts/ci/android/smoke-test.sh "$RUNNER_TEMP/apk/$APK" "$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: android-smoke-test + path: ${{ runner.temp }}/smoke-test + 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: android-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 Android 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: android-emu-claude-${{ github.ref }} + cancel-in-progress: true + env: + # the same in all 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 libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core + dpkg-query -W libpulse0 libegl1 imagemagick sqlite3 fonts-dejavu-core + + - name: Download app + uses: actions/download-artifact@v8 + with: + name: android-app + path: ${{ runner.temp }}/apk + + - 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" + + - name: Install emulator and system image + run: scripts/ci/android/install-sdk.sh "$RUNNER_TEMP/android-sdk" + + - name: Boot emulator + run: scripts/ci/android/emulator.sh start "$RUNNER_TEMP/claude-test/screens" + + - name: Reach the test backend + if: steps.backend.outcome == 'success' + run: scripts/ci/android/emulator.sh reverse 5223 + + # 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/android/ui "$@"\n' "$PWD" | sudo tee /usr/local/bin/ui + sudo chmod +x /usr/local/bin/ui + + - name: Start the app + run: scripts/ci/android/smoke-test.sh "$RUNNER_TEMP/apk/$APK" "$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" scripts/ci/android/claude-test.md + + - 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: android-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: android-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 in the emulator + 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: Install tools + run: | + sudo apt-get update + sudo apt-get install -y libpulse0 libegl1 + dpkg-query -W libpulse0 libegl1 + + - name: Download released app + run: scripts/ci/android/release-apk.sh "$RUNNER_TEMP/release" + + - name: Install emulator and system image + run: scripts/ci/android/install-sdk.sh "$RUNNER_TEMP/android-sdk" + + - name: Boot emulator + run: scripts/ci/android/emulator.sh start "$RUNNER_TEMP/smoke-test" + + - name: Run smoke test + run: scripts/ci/android/smoke-test.sh "$RUNNER_TEMP/release/simplex-aarch64.apk" "$RUNNER_TEMP/smoke-test" onboarding + + - name: Upload smoke test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: android-release-smoke-test + path: ${{ runner.temp }}/smoke-test + retention-days: 3 + if-no-files-found: warn diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml index d1186018aa..82725791f5 100644 --- a/.github/workflows/desktop-linux.yml +++ b/.github/workflows/desktop-linux.yml @@ -1,14 +1,6 @@ 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: @@ -29,8 +21,7 @@ permissions: 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 }} + group: desktop-linux-${{ github.ref }} cancel-in-progress: true env: @@ -43,13 +34,6 @@ env: 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: @@ -65,9 +49,9 @@ jobs: - 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]') }} + CLAUDE: ${{ inputs.claude }} + BACKEND: ${{ inputs.backend }} + RELEASE: ${{ inputs.release }} run: | echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT" echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT" diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml index a275bb82d6..08502674d9 100644 --- a/.github/workflows/ios-sim.yml +++ b/.github/workflows/ios-sim.yml @@ -1,13 +1,6 @@ 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: @@ -24,8 +17,7 @@ permissions: 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 }} + group: ios-sim-${{ github.ref }} cancel-in-progress: true env: @@ -41,13 +33,6 @@ env: 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: @@ -62,8 +47,8 @@ jobs: - 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]') }} + CLAUDE: ${{ inputs.claude }} + BACKEND: ${{ inputs.backend }} run: | echo "claude=$CLAUDE" | tee -a "$GITHUB_OUTPUT" echo "backend=$BACKEND" | tee -a "$GITHUB_OUTPUT" diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index d8543735b0..7358bfdc00 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -75,7 +75,7 @@ public let groupAppDefaults: [String: Any] = [ GROUP_DEFAULT_NTF_ENABLE_PERIODIC: false, GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue, GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue, - GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue, + GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.always.rawValue, GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue, GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS: SMPWebPortServers.preset.rawValue, GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpConnectTimeout.backgroundTimeout, @@ -258,7 +258,7 @@ public let networkSessionModeGroupDefault = EnumDefault( public let networkSMPProxyModeGroupDefault = EnumDefault( defaults: groupDefaults, forKey: GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE, - withDefault: .unknown + withDefault: .always ) public let networkSMPProxyFallbackGroupDefault = EnumDefault( 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 09c494dc76..094615eee1 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 @@ -414,6 +414,13 @@ object ChatModel { } } + suspend fun addSentChatItem(activeCtx: ChatsContext, rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + activeCtx.addChatItem(rhId, cInfo, cItem) + if (activeCtx.secondaryContextFilter != null && cInfo.inMainChatList) { + chatsContext.addChatItem(rhId, cInfo, cItem) + } + } + // Spec: spec/state.md#ChatsContext class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) { val chats = mutableStateOf(SnapshotStateList()) @@ -584,9 +591,9 @@ object ChatModel { val i = getChatIndex(rhId, cInfo.id) val chat: Chat if (i >= 0) { - chat = chatsContext.chats[i] + chat = chats[i] // update preview (for chat from main scope to show new items for invitee in pending status) - if (cInfo.groupChatScope() == null || cInfo.groupInfo_?.membership?.memberPending == true) { + if (cInfo.inMainChatList) { val newPreviewItem = when (cInfo) { is ChatInfo.Group -> { val currentPreviewItem = chat.chatItems.firstOrNull() @@ -604,20 +611,20 @@ object ChatModel { else -> cItem } val wasUnread = chat.unreadTag - chatsContext.chats[i] = chat.copy( + chats[i] = chat.copy( chatItems = arrayListOf(newPreviewItem), chatStats = - if (cItem.meta.itemStatus is CIStatus.RcvNew) { + if (cItem.meta.itemStatus is CIStatus.RcvNew && secondaryContextFilter == null) { increaseUnreadCounter(rhId, currentUser.value!!) chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) } else chat.chatStats ) - updateChatTagReadInPrimaryContext(chatsContext.chats[i], wasUnread) + updateChatTagReadInPrimaryContext(chats[i], wasUnread) } // pop chat if (appPlatform.isDesktop && cItem.chatDir.sent) { - reorderChat(chatsContext.chats[i], 0) + reorderChat(chats[i], 0) } else { popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) } @@ -1872,6 +1879,9 @@ sealed class ChatInfo: SomeChat, NamedChat { val isChannel: Boolean get() = groupInfo_?.useRelays == true + + val inMainChatList: Boolean + get() = groupChatScope() == null || groupInfo_?.membership?.memberPending == true } @Serializable 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 e9dbb3b157..960efd0350 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 @@ -723,35 +723,39 @@ fun ChatView( ciInfo } groupMembersJob.cancel() - groupMembersJob = scope.launch(Dispatchers.Default) { - var initialCiInfo = loadChatItemInfo() ?: return@launch + groupMembersJob = scope.launch { + var initialCiInfo = withContext(Dispatchers.Default) { loadChatItemInfo() } ?: return@launch if (!ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)) { ModalManager.end.closeModals() + } else if (ModalManager.end.isTopModalOpen(ModalViewId.CHAT_ITEM_INFO)) { + ModalManager.end.closeModal() } - ModalManager.end.showModalCloseable(endButtons = { + ModalManager.end.showModalCloseable(id = ModalViewId.CHAT_ITEM_INFO, endButtons = { ShareButton { clipboard.shareText(itemInfoShareText(chatModel, initialCiInfo.first, initialCiInfo.second, chatModel.controller.appPrefs.developerTools.get())) } }) { close -> - var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } - val (item, info) = ciInfo - ChatItemInfoView(chatRh, item, info, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) - LaunchedEffect(cItem.id) { - withContext(Dispatchers.Default) { - for (msg in controller.messagesChannel) { - if (msg.rhId == chatRh && - msg is API.Result && - msg.res is CR.ChatItemsStatusesUpdated && - msg.res.chatItems.any { it.chatItem.id == cItem.id } - ) { - ciInfo = loadChatItemInfo() ?: return@withContext - initialCiInfo = ciInfo + key(this) { + var ciInfo by remember(cItem.id) { mutableStateOf(initialCiInfo) } + val (item, info) = ciInfo + ChatItemInfoView(chatRh, item, info, devTools = chatModel.controller.appPrefs.developerTools.get(), chatInfo) + LaunchedEffect(cItem.id) { + withContext(Dispatchers.Default) { + for (msg in controller.messagesChannel) { + if (msg.rhId == chatRh && + msg is API.Result && + msg.res is CR.ChatItemsStatusesUpdated && + msg.res.chatItems.any { it.chatItem.id == cItem.id } + ) { + ciInfo = loadChatItemInfo() ?: return@withContext + initialCiInfo = ciInfo + } } } } - } - KeyChangeEffect(chatModel.chatId.value) { - close() + KeyChangeEffect(chatModel.chatId.value) { + close() + } } } } 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 357a010d2c..736bb919e5 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 @@ -333,7 +333,7 @@ fun showLargeFileAlert(fileSize: Long, incognito: Boolean, senderProfile: LocalP text = text, confirmText = generalGetString(MR.strings.ok), dismissText = generalGetString(MR.strings.badges_support_simplex_title), - onDismiss = ::openBadgesView + onDismiss = { if (!appPlatform.isDesktop || !ModalManager.end.hasModalOpen(ModalViewId.BADGES)) openBadgesView() } ) } else { AlertManager.shared.showAlertMsg(title, text) @@ -599,7 +599,7 @@ fun ComposeView( if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) + chatModel.addSentChatItem(chatsCtx, chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) } } return chatItems.first().chatItem @@ -741,7 +741,7 @@ fun ComposeView( withContext(Dispatchers.Main) { chatItems?.forEach { chatItem -> - chatsCtx.addChatItem(rhId, chat.chatInfo, chatItem) + chatModel.addSentChatItem(chatsCtx, rhId, chat.chatInfo, chatItem) } } 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 ca370c1b52..d1b56add6b 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 @@ -494,7 +494,7 @@ fun sendCommandMsg(chatsCtx: ChatModel.ChatsContext, chat: Chat, msg: String) { if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) + chatModel.addSentChatItem(chatsCtx, chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) } } } 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 index 189e69a925..15758a7296 100644 --- 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 @@ -96,11 +96,14 @@ fun showMarkdownFile(file: CIFile) { formatted, bidiControlChar.containsMatchIn(text) ) - ModalManager.center.showModal(endButtons = { - if (formatted != null) MarkdownViewSwitch() - MarkdownFileSaveButton(file, bytes) - }) { - MarkdownFileView(file.fileName, document) + withContext(Dispatchers.Main) { + if (appPlatform.isDesktop && ModalManager.center.isTopModalOpen(ModalViewId.MARKDOWN_FILE)) ModalManager.center.closeModal() + ModalManager.center.showModal(id = ModalViewId.MARKDOWN_FILE, endButtons = { + if (formatted != null) MarkdownViewSwitch() + MarkdownFileSaveButton(file, bytes) + }) { + key(this) { MarkdownFileView(file.fileName, document) } + } } } } 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 9b3162744d..f28717e115 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 @@ -65,21 +65,24 @@ private fun openMarkdownModal(modal: Format.Modal) { } private fun showFullProfileDescription(description: String) { - ModalManager.end.showModalCloseable { _ -> - ColumnWithScrollBar { - AppBarTitle(generalGetString(MR.strings.profile_description__field)) - val parsed = parseToMarkdownText(description) - MarkdownText( - 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, - ) + if (ModalManager.end.isTopModalOpen(ModalViewId.PROFILE_DESCRIPTION)) ModalManager.end.closeModal() + ModalManager.end.showModalCloseable(id = ModalViewId.PROFILE_DESCRIPTION) { _ -> + key(this) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.profile_description__field)) + val parsed = parseToMarkdownText(description) + MarkdownText( + 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, + ) + } } } } 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 ea567d7da6..e3f6b92be8 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 @@ -274,8 +274,12 @@ fun UserPicker( painterResource(MR.images.ic_manage_accounts), generalGetString(MR.strings.create_chat_profile), { + if (ModalManager.center.isTopModalOpen(ModalViewId.CREATE_PROFILE)) { + userPickerState.value = AnimatedViewState.HIDING + return@UserPickerOptionRow + } doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { - ModalManager.center.showModalCloseable { close -> + ModalManager.center.showModalCloseable(id = ModalViewId.CREATE_PROFILE) { close -> LaunchedEffect(Unit) { userPickerState.value = AnimatedViewState.HIDING } 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 036bcab68d..b09efe7d1d 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 @@ -252,7 +252,7 @@ fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) text = supports, confirmText = generalGetString(MR.strings.ok), dismissText = generalGetString(MR.strings.badges_support_simplex_title), - onDismiss = ::openBadgesView + onDismiss = { if (!appPlatform.isDesktop || !ModalManager.end.hasModalOpen(ModalViewId.BADGES)) openBadgesView() } ) } else { AlertManager.shared.showAlertMsg(title = title, text = supports) 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 3655c4053d..7f6ee13d98 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import LocalCardScreen @@ -93,7 +94,11 @@ enum class ModalViewId { CONTEXT_USER_PICKER_INCOGNITO, BADGE_LINK, BADGE_REDEEM_CODE, - BADGES + BADGES, + CREATE_PROFILE, + PROFILE_DESCRIPTION, + MARKDOWN_FILE, + CHAT_ITEM_INFO } class ModalManager(private val placement: ModalPlacement? = null) { @@ -118,6 +123,8 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id + fun isTopModalOpen(id: ModalViewId): Boolean = modalViews.getOrNull(modalViews.size - toRemove.size - 1)?.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) { @@ -224,6 +231,10 @@ class ModalManager(private val placement: ModalPlacement? = null) { if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) { runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } } } + // Until the opening modal covers the page that opened it, or the view under the first modal, a second click there would open it again + if (transition.targetState == EnterExitState.PostExit && it < modalCount.value) { + Box(Modifier.fillMaxSize().pointerInput(Unit) {}) + } } } 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 f6109778dc..82cffbb384 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 @@ -66,8 +66,9 @@ fun ContactConnectionInfoView( deleteConnection = { deleteContactConnectionAlert(rhId, contactConnection, chatModel, close) }, onLocalAliasChanged = { setContactAlias(rhId, contactConnection, it, chatModel) }, learnMore = { + if (appPlatform.isDesktop) ModalManager.end.closeModals() ModalManager.end.showModalCloseable { close -> - AddContactLearnMore(close) + key(this) { AddContactLearnMore(close) } } } ) @@ -90,7 +91,10 @@ private fun ContactConnectionInfoLayout( SettingsActionItemWithContent( icon = painterResource(MR.images.ic_theater_comedy_filled), text = null, - click = { ModalManager.end.showModal { IncognitoView() } }, + click = { + if (appPlatform.isDesktop) ModalManager.end.closeModals() + ModalManager.end.showModal { key(this) { IncognitoView() } } + }, iconColor = Indigo, extraPadding = false ) { 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 e91d6c2ce0..5d8ecec60b 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 @@ -47,8 +47,9 @@ fun UserProfilesView(m: ChatModel, search: MutableState, profileHidden: showHiddenProfilesNotice = m.controller.appPrefs.showHiddenProfilesNotice, visibleUsersCount = visibleUsersCount(m), addUser = { + if (ModalManager.center.isTopModalOpen(ModalViewId.CREATE_PROFILE)) return@UserProfilesLayout withAuth { - ModalManager.center.showModalCloseable { close -> + ModalManager.center.showModalCloseable(id = ModalViewId.CREATE_PROFILE) { close -> CreateProfile(m, close) } } diff --git a/plans/2026-06-13-chat-list-wrong-chat-preview-fix.md b/plans/2026-06-13-chat-list-wrong-chat-preview-fix.md new file mode 100644 index 0000000000..171612edc2 --- /dev/null +++ b/plans/2026-06-13-chat-list-wrong-chat-preview-fix.md @@ -0,0 +1,157 @@ +# Chat list: message preview and unread counter applied to the wrong chat + +**PR:** #7072 · **Files:** `ChatModel.kt` (`ChatsContext.addChatItem`, `ChatModel.addSentChatItem`, `ChatInfo.inMainChatList`), `ComposeView.kt`, `FramedItemView.kt` + +## Symptom + +While a secondary chat view is open — "Chat with admins", "chat with member", or a +group's reports view — an incoming message could land on an unrelated chat in the +main list: that chat took the message as its last-message preview and its unread +badge incremented. The wrong preview stays until that chat's next message or until the chat list is reloaded; the wrong +unread count until the chat is opened (opening reloads its stats). + +## Root cause + +`ChatsContext` is a nested class with two live instances: the primary chat list +(`ChatModel.chatsContext`, shown in the main list) and a secondary context created +per reports / member-support view (set up in `ChatListNavLinkView.kt`, `MemberSupportChatView.kt`, +`GroupReportsView.kt`). `SimpleXAPI` dispatches every incoming item to **both**. + +Inside `addChatItem`: + +```kotlin +val i = getChatIndex(rhId, cInfo.id) // searches THIS context's own list +... +chat = chatsContext.chats[i] // but reads the PRIMARY singleton's list +chatsContext.chats[i] = chat.copy(...) // ...and writes it +``` + +`getChatIndex` is a member of `ChatsContext`, so `chats` resolves to the receiver +context. `chatsContext` (unqualified) is a member of the enclosing `object ChatModel` +and always resolves to the **primary** instance. On the secondary context the two +lists hold different chats in a different order, so `i` — a secondary-list index — +points at an unrelated chat in the primary list, which then gets clobbered. + +### Why the indices diverge + +The secondary context's chat list starts empty (only the primary is seeded on load) and +grows by `addChat` at index 0 as chats receive messages. The two lists fall out of step +when a change reaches only one of them, for example: +- a message sent in "Chat with admins" creates the group's entry only in the secondary + list; +- a chat added to or removed from the primary list only (a group invitation, a contact + request); +- a burst of messages within 2 s: the primary list's pop is throttled, while the + secondary `addChat` inserts at index 0 at once. + +Until #7404 is merged, the secondary pop collector rebuilds the secondary list from the primary +one, which re-aligns them now and then, so the bug is intermittent. + +### How it was introduced + +Commit `b97e1e0f1` (#5909, "show new messages from support scope in main chat preview +when invitee is pending") changed `chats[i]` → `chatsContext.chats[i]` at four sites +while broadening the preview guard with `|| memberPending`. The guard change was the +feature for received items; the `chatsContext.` qualifier also made the invitee's sent +support messages reach the main list, but at an index from the secondary list, which +on the secondary context updates the wrong chat. + +## The fix + +Use the receiver context's own list at all four sites, keeping the broadened guard: + +```diff +- chat = chatsContext.chats[i] ++ chat = chats[i] +- chatsContext.chats[i] = chat.copy( ++ chats[i] = chat.copy( +- updateChatTagReadInPrimaryContext(chatsContext.chats[i], wasUnread) ++ updateChatTagReadInPrimaryContext(chats[i], wasUnread) +- reorderChat(chatsContext.chats[i], 0) ++ reorderChat(chats[i], 0) +``` + +This is a byte-for-byte revert of the qualifier from `b97e1e0f1`, with the feature's +guard preserved. `chats[i]` get/set is the idiom used elsewhere in the class. + +## The revert alone would break #5909 + +A pending invitee's own **sent** support message reaches only the **active** context — +`ComposeView` send/forward and `FramedItemView` command-send all call +`chatsCtx.addChatItem`, and in a member-support chat `chatsCtx` is the *secondary* +context. Before this change that call wrote `chatsContext.chats[i]`, i.e. the primary +list, so the group's main-list preview did update — but only as a side effect of the +bug, at whatever index happened to line up. + +Reverting the four sites therefore removes it: the secondary updates its own list and +the primary is never touched, so the invitee's sent support messages stop appearing as +the group's preview. Confirmed by testing during review. + +So the revert is paired with an explicit dispatch, which does the same thing on purpose +and at the right index: + +```kotlin +suspend fun addSentChatItem(activeCtx: ChatsContext, rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + activeCtx.addChatItem(rhId, cInfo, cItem) + if (activeCtx.secondaryContextFilter != null && cInfo.inMainChatList) { + chatsContext.addChatItem(rhId, cInfo, cItem) + } +} +``` + +`ChatInfo.inMainChatList` (`groupChatScope() == null || membership.memberPending`) is +the rule `addChatItem` applies to decide whether an item updates the preview of a chat +already in the list; both places use the property. + +Cross-context dispatch stays with the **caller**, matching how `processReceivedMsg` +hands received items to both contexts and how `ChatView` handles deletes. No +`ChatsContext` method writes the other context — the invariant this fix restores +(the pop collector still reads the primary list until #7404). + +Passing the scoped `cInfo` to the primary is safe: when the group is in the main list +`updateChatInfo` strips the scope before storing it (when it isn't, `addChat` stores it +as received items already do), and `chatItemBelongsToScope` returns false in the primary context +for a scoped `cInfo`, so the item never enters the primary item list. + +## Why it is safe + +- The four-site revert changes nothing for the **primary** context: + `chats === chatsContext.chats` there, so both forms resolve to the same list. The + secondary context stops touching the wrong one. The primary context additionally + receives items sent from a secondary view that belong in the main list (the pending + invitee's support messages; also a bot command tapped in the reports view, which the + main chat now shows). +- `updateChatTagReadInPrimaryContext` and `increaseUnreadCounter` (through + `changeUnreadCounterInPrimaryContext`) already return early in a secondary context, + so they stay primary-only either way. +- `popChatCollector` is per-context (`PopChatCollector(this)`), so the pop/reorder + calls write the context they run in (until #7404 the collector reads the primary + list; see "A related defect" below). +- The unread stats increment is limited to the primary context + (`secondaryContextFilter == null`). The secondary context's own entry for the open + group is what `ChatView` reads for the unread button inside the open view; without + the limit it would count main-scope group messages that the view doesn't show. The + limit keeps increments out of that entry, as #7559 does for chats added to the list. +- iOS is unaffected — `ItemsModel` holds only chat items and the chat list is a single + `chats` array on `ChatModel`, so the mis-indexing cannot occur there. + +## Alternatives considered + +- **Guard the whole block to `secondaryContextFilter == null`.** Looked smaller, but + the secondary list would then stop getting the preview and order updates that + received items give it, and the block would branch on the context type. +- **Compute `i` against the primary list and keep the primary writes.** Would make the + secondary call double-update the primary preview and unread, and never update the + secondary list at all. + +## A related defect, fixed separately + +`PopChatCollector` is nested but not `inner`, so the unqualified `getChat` / `chats` +inside `popCollectedChats()` also resolve to `ChatModel` — the primary list — while +the result is written to `chatsCtx.chats`. Same class of defect, different symptom +(it corrupts the secondary context's own list, not the main one). Fixed in #7404. + +## Verification + +Desktop, master vs this PR, with "Chat with admins" open in a group the user does not +own: see the PR description for the reproduction and the before/after screenshots. diff --git a/plans/2026-10-15-modal-opens-again-on-repeat-click.md b/plans/2026-10-15-modal-opens-again-on-repeat-click.md new file mode 100644 index 0000000000..4360d4c288 --- /dev/null +++ b/plans/2026-10-15-modal-opens-again-on-repeat-click.md @@ -0,0 +1,80 @@ +# A page opens again on every click of the button that opened it + +Fixes #6270. + +## Problem + +Issue #6270 reports one case: on desktop (Linux), clicking "Learn more" repeatedly in a pending connection opens the page again on every click. Each copy then needs its own Back. The same bug has two causes, and both occur across the app: + +1. **The button stays clickable in another pane.** On desktop the window has a left pane (chat list, `ModalManager.start`), a center pane (chat or `ModalManager.center`) and a right pane (`ModalManager.end`). Each pane draws only the top modal of its own stack. When a button in one pane opens a page in another, the button stays visible and clickable, and every click pushes another copy. +2. **A fast double click during the slide-in.** A modal slides in over 250 ms. Until it covers the button that opened it, the button still takes clicks, so a double click opens two copies. Pages slide in on Android and in the desktop left pane; in the desktop center and right panes the first page never slides, the third and later do, and the second does only after an earlier page there has slid out. + +Reproduced on the master prebuild (a932c82), R8 on the stable prebuild (master has the same code): + +| # | Steps | Clicks | Backs needed | +|---|---|---|---| +| R1 | Pending connection: "Learn more" or "Incognito" | 3 | 3 | +| R2 | Your chat profiles: "+ Add profile" (form opens in the center) | 3 | 3 | +| R3 | "Chat with admins" open on the right (a reports chat opens the same way; not tried); in the group chat, message menu → "Info" | 3 | 3 | +| R4 | Direct chat banner: profile bio "Read more" | 3 | 3 | +| R5 | Attach a file over 1 GB; "Large file" alert → "Support SimpleX" | 3 | 3 | +| R6 | `.md` file clicked in a support chat on the right (viewer opens in the center) | 3 | 3 | +| R7 | What's New opened in the center after an update → "Create your address" → "Address settings" | 3 | 3 | +| R8 | No profile and no linked mobile: picker → "Create chat profile" | 3 | 3 | +| D1 | Settings: double-click "Appearance" (80 ms, 150 ms) | 2 | 2 | +| D2 | Settings: double-click "Network & servers" (80 ms) | 2 | 2 | +| D3 | User menu: double-click "Your chat profiles" (80 ms) | 2 | 2 | + +Controls that don't stack on master: message "Info" with nothing open on the right, a double click on "Group preferences" when group info is the only page on the right, a double click on the new-chat pencil. + +## Audit + +Every `ModalManager.{start,center,end,fullscreen}.show*` call on master (about 170) was classified by where its button is drawn on desktop and whether that button stays clickable after the page opens. Wrapper functions and every caller of each view were followed. The cases above are all those found. All other call sites are covered by the page they open, guarded already, or open a fullscreen modal, which covers every pane. + +Existing guards: close the target pane first (`ChatListNavLinkView` "Set contact name", the pending-connection branch of `ChatView`), skip if a page with that id is open (`BadgesSupportSimplexView`, `BADGE_REDEEM_CODE`), toggles (the chat info button, the profile picker's incognito info), and the desktop overlay in `DesktopScreen` that closes left-pane pages on a click in the center (it guards the onboarding cards there). + +## Fix + +### Double click: block clicks on the page being covered (`ModalManager.showInView`) + +`AnimatedContent` draws each page in a child with its own enter or exit transition. When a modal opens, the child sliding out (`targetState == PostExit`, below the new count) also draws an empty `pointerInput` box over itself. That child is the page being covered, which is the page that opened the new one when the button is in the same pane, or, for the first modal of a pane, the empty child, whose box covers the chat list or chat under the pane. The rest of the pane is under the page sliding in, whose `Surface` already takes clicks. Nothing is blocked when a page closes, as on master. + +This fixes the double click for every animated modal on both platforms, with no change at the call sites. + +### Buttons in another pane + +Each site uses an existing guard, or "replace" (an on-top check followed by a close and a show), chosen by where its button can be and what the page shows: + +| Site | Guard | Why this one | +|---|---|---| +| `ContactConnectionInfoView` "Learn more", "Incognito" (R1) | desktop: close the right pane first | The button is never in the right pane on desktop. On Android the view can itself be the open modal ("Set contact name"), so the call is desktop only. | +| The large-file and badge alerts' "Support SimpleX" (R5), which call `openBadgesView` | desktop: skip if `BADGES` is open | Badges can also be open under other right-pane pages, so the right pane can't be closed. `BADGES` is already set on this modal. Badges sub-pages stack above it, so the check is "open", not "on top": while an open Badges page is covered by another page, these two buttons do nothing. The check is at the two alerts, not in `openBadgesView`, so the "Larger files, longer" row in message Info, which its Badges page covers, is unchanged. On Android the page covers its button. | +| `UserProfilesView` "+ Add profile" (R2), `UserPicker` "Create chat profile" (R8) | skip if `CREATE_PROFILE` is on top | Closing the center would also close What's New when it is open there, and clear `chatModel.secondaryChatsContext` while a support chat is open on the right (`closeModals()` always clears it). Both clicks open the same empty form, so the open form, with anything already typed, is kept. Both check before asking for authentication, and the picker row also hides the picker, as the form does when it opens. "On top" rather than "open": with no chat selected the center stops drawing its page stack once its last page has closed, so a closed page can stay in its list. | +| `showFullProfileDescription` "Read more" (R4) | replace if `PROFILE_DESCRIPTION` is on top | Also opened from info pages in the right pane. The text depends on which link was clicked. | +| `showMarkdownFile` (R6) | desktop: replace if `MARKDOWN_FILE` is on top, on the main thread | The opener can be a support chat in the right pane, which must stay. Another file must show its own text. The file is read on a background thread, so the close and show run on the main thread, where the pane reads its stack. On Android the viewer covers its file once it has loaded; taps during the load are the async case under Not fixed. | +| `showItemDetails` message "Info" (R3) | when a secondary chat is open, replace if `CHAT_ITEM_INFO` is on top | Info already closes the right pane first unless a secondary chat is open there. In that case it was pushed over the support or reports chat on every click. The first Info over the support chat still opens over it, and Back still returns to it. The job now runs on the chat's UI scope with only the load on a background thread, so the close and show run on the main thread, as `showReports` does. | + +"Replace" closes and shows in one main-thread event, so the new page is drawn in the same place as the old one, from a lambda of the same call site, and Compose would keep the old page's state. That includes `AppBarTitle`'s `LaunchedEffect(title)`, so when the title is unchanged it would not reach the new page's app bar. Each replaced page, and each #6270 row, wraps its content in `key(this)`, the `ModalData` of that show, so it starts fresh. + +The "on top" checks use a new `isTopModalOpen`, which looks at the visible top modal (the same index as `isLastModal`). A modal closed with animation stays in `modalViews` until its slide-out ends, and `isLastModalOpen` still counts it: "replace" during those 250 ms would then call `closeModal()` on a page already closing, and `closeModal()` would close the page under it. `isLastModalOpen` and its callers are unchanged. + +### Not fixed + +- **R7, What's New "Create your address".** The address page opened from What's New in the center pushes its own pages (Address settings, SimpleX name, 1-time link, Learn more) to the left pane, beside it. Opening it in the left pane instead, where they would cover it, makes it disposed while covered: on Back it is composed again, its `autoCreateAddress` effect runs again (re-creating an address the user just deleted) and its "share with contacts" state resets. The Settings → What's New path and Android already have that flaw. Fixing it needs `UserAddressView` to keep that state across disposal, which a follow-up PR (#7756) does. +- Async openers that load data before showing a page, e.g. the reports and support bars, the add-members, channel-members and relays rows in group info, and on Android a `.md` file tapped twice while it loads. Two clicks within the load time can still push two pages. This is a different mechanism from the slide-in. It reproduces on master (reports bar, "Invite", "Invite members", "Member reports", "Chats with members" rows) and is fixed in a follow-up PR (#7757). +- On desktop, two different `.md` files clicked before either has loaded: the one that finishes loading last is shown, as on master; Back now returns to the chat instead of the other file. +- `OpenWhenReceivedEffect`: a file opened when its download finishes is not a repeated click. An image opens a second fullscreen viewer over an open one, as before. On desktop a `.md` file opened this way now replaces an open `.md` viewer, so Back returns to the chat instead of the other file. +- Keyboard and accessibility activation are not pointer input and are not blocked during a slide. +- The `BADGES` check (`hasModalOpen`) also counts a page that is sliding out, so on desktop a click within 250 ms of closing that page, if it slid out, does nothing; a click after the slide-out works. +- A different page opened from the same view stacks over the first one, for example bio "Read more" over contact info, and Back returns to the first page. That is normal navigation. Of the buttons this change touches, only #6270's two rows, "Incognito" and "Learn more", replace each other. + +## Effect + +- Desktop: each fixed button opens its page once. A repeated click keeps the open page or replaces it with what was clicked. The on-top check doesn't depend on which button opened the page, so a bio, `.md` viewer or Info opened from one place is also replaced by one opened from another (for example a member's bio by a business chat banner's "Read more"), and Back then returns to the page under it. Message Info that replaces Info with nothing else on the right also starts fresh now: master kept the previous page's selected tab and, when both messages had the same title, its app bar showed none. +- Both platforms: while a modal slides open, the page it covers in its pane, or the view under a pane's first modal, takes no new pointer input (clicks, drags, wheel scroll, hover) for 250 ms. On desktop the first page of the center and right panes appears without a slide, so it has no such window. +- Both platforms: a keyed page (bio, `.md` viewer, Info, #6270's pages) opened again while the previous one is still sliding out after Back now starts fresh; master reused the closing page's remembered state (for example Info's selected tab), and its app bar showed no title when the title was unchanged. +- Android: the slide window also covers modals the app opens itself (What's New at start), and a double-tapped toggle (chat info, the profile picker's incognito info, and the reports and support-chats bars when the second tap comes after their page is pushed) now stays open; a second tap during the reports or support-chat load still opens a second page (see Not fixed). Apart from these and the fresh state above, the per-site checks act only on desktop, where the button stays visible. + +## Verification + +Desktop: the same steps on the master prebuild and this branch, before and after, plus the areas the change touches (info and support chats in the right pane, profiles, badges, markdown viewer, Settings navigation and Back). Android (Waydroid): double tap on list rows, Back during a transition, and the touched flows. diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 2f5660dfc1..7034ea8e7e 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -1,36 +1,37 @@ # App tests on GitHub's runners -Two workflows build the apps from a branch's own code and test them, only when asked: `ios-sim` (the iOS app in a simulator on a macOS runner) and `desktop-linux` (the desktop app on a Linux runner). Each runs a scripted smoke test and, on request, a Claude session that follows a test plan, and a test backend (relay, directory, names server, peers). Details: [iOS](ios-sim/README.md), [desktop](desktop-linux/README.md). +Three workflows build the apps from a branch's own code and test them, only when asked: `ios-sim` (the iOS app in a simulator on a macOS runner), `desktop-linux` (the desktop app on a Linux runner) and `android-emu` (the Android app in an emulator on a Linux runner). Each runs a scripted smoke test and, on request, a Claude session that follows a test plan, and a test backend (relay, directory, names server, peers). Details: [iOS](ios-sim/README.md), [desktop](desktop-linux/README.md), [Android](android/README.md). -## Run tags +## Starting a run -Put a tag in the message of the commit you push to a branch named `f/…`. Tags combine: each workflow runs everything its tags ask for. +Pushes start nothing: a run is started by hand, on any branch, with inputs that are off by default and combine. -| Tag | iOS | Desktop | -|---|---|---| -| `[app-ci]` | scripted test | scripted test | -| `[app-ci:ios]` | scripted test | — | -| `[app-ci:desktop]` | — | scripted test | -| `[claude-test]` | scripted test, then Claude | scripted test, then Claude | -| `[claude-test:ios]` | scripted test, then Claude | — | -| `[claude-test:desktop]` | — | scripted test, then Claude | -| `[ci-backend]`, with one of the above | also the test backend | also the test backend | -| `[ci-release]`, with one of the above | — | also the latest release's onboarding, to tell a branch's failure from the runner's | +| Input | iOS | Desktop | Android | +|---|---|---|---| +| no input | scripted test | scripted test | scripted test | +| `claude` | scripted test, then Claude | scripted test, then Claude | scripted test, then Claude | +| `backend` | also the test backend | also the test backend | also the test backend | +| `release` | — | also the latest release's onboarding, to tell a branch's failure from the runner's | also the latest release's onboarding, to tell a branch's failure from the runner's | -Only the pushed head commit counts, and a tag counts anywhere in its message, body included: never write one in a message unless that push should run it. A push that asks for nothing starts no job and doesn't cancel a running test; a newer requested run on the same branch cancels the older one. Tags start runs only on `f/…` branches, so pull requests from other branches don't list skipped runs; on any other branch, master and stable included, run a workflow by hand. +In the Actions tab: `ios-sim`, `desktop-linux` or `android-emu` → Run workflow, choose the branch and tick the inputs. With the GitHub CLI, each input is a field: -## Running a workflow by hand +```sh +gh workflow run ios-sim.yml --ref +gh workflow run ios-sim.yml --ref -f claude=true +gh workflow run desktop-linux.yml --ref -f claude=true -f backend=true -f release=true +gh workflow run android-emu.yml --ref -f claude=true +``` -Actions → `ios-sim` or `desktop-linux` → Run workflow, on any branch, with checkboxes that are off by default: `claude` (the Claude session), `backend` (the test backend) and, on desktop, `release` (the latest release's onboarding). +A newer run of a workflow on the same branch cancels the older one. ## How long a run takes -| Run | iOS | Desktop | -|---|---|---| -| Scripted test, builds reused | about 25 min, plus the wait for a macOS runner, which the team's builds share (minutes to an hour) | about 10 min | -| The core rebuilt, after Haskell changes | 30–45 min more | about 25 min more | -| With the test backend | 20–45 min more when it is rebuilt, then about 10 min to start | about 30 min more when it is rebuilt, then a few minutes to start | -| With Claude on the smoke plan | 10–15 min more | about 4 min more | +| Run | iOS | Desktop | Android | +|---|---|---|---| +| Scripted test, builds reused | about 25 min, plus the wait for a macOS runner, which the team's builds share (minutes to an hour) | about 10 min | about 15 min | +| The core rebuilt, after Haskell changes | 30–45 min more | about 25 min more | about 27 min more | +| With the test backend | 20–45 min more when it is rebuilt, then about 10 min to start | about 30 min more when it is rebuilt, then a few minutes to start | as desktop, whose backend it shares | +| With Claude on the smoke plan | 10–15 min more | about 4 min more | about 5 min more | ## The Claude job diff --git a/scripts/ci/android/README.md b/scripts/ci/android/README.md new file mode 100644 index 0000000000..b18e79a619 --- /dev/null +++ b/scripts/ci/android/README.md @@ -0,0 +1,52 @@ +# Android app CI in an emulator + +`.github/workflows/android-emu.yml` builds the core library and a debug APK from the branch on a GitHub runner (Ubuntu 24.04), installs the app in an Android emulator, and drives it: onboarding to the chat list, then the chat list with the reachable toolbar (the default) and with the top bar. On request it also runs Claude Code driving the same path, a test backend, and the latest release as a baseline. + +## Running it + +The workflow runs only when started by hand, on any branch: in the Actions tab, `android-emu` → Run workflow, or `gh workflow run android-emu.yml --ref ` with `-f claude=true`, `-f backend=true` or `-f release=true`. [App tests on GitHub's runners](../README.md) lists the inputs. A newer run on the same branch cancels the older one. + +Every run builds the core library, and the backend when it's asked for, from the branch's own Haskell code. It reuses an earlier build only when the sources are unchanged, as an artifact of the same branch named after a hash of its sources (30 days): for the core `src/**`, `cabal.project`, `simplex-chat.cabal`, `PRIVACY.md`, the Nix files (`flake.nix`, `flake.lock`, `scripts/nix/**`) and `build-core.sh`; the backend is desktop-linux's, under its name and hash, so a backend either workflow built is reused by both. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`. + +## Jobs + +| Job | Does | Time | +|---|---|---| +| `plan` | reads the inputs, finds earlier core and backend builds | 10 s | +| `core` | `build-core.sh`: `lib:simplex-chat` and `lib:support` for aarch64-android with Nix, as `scripts/android/build-android.sh` builds them | skipped when reused, about 27 min not | +| `app` | the debug APK (`:android:assembleFossDebug` for arm64-v8a only), the emulator, `smoke-test.sh` | about 13 min: APK 7–8, emulator 1.5, smoke test 3.5 (4 with the backend case, after 2.5 to start the backend) | +| `claude` (opt-in) | after `app` passed: its APK in a new emulator at the first screen, then Claude Code drives it | about 5 min, Claude 2.5 | +| `backend` (opt-in) | desktop-linux's `build-backend.sh` | about 30 min, skipped when reused | +| `release` (opt-in) | the latest release's arm64 APK, its signer checked against SimpleX's key, onboarding only | about 4 min | + +Results are artifacts: `android-smoke-test` (numbered screenshots, the accessibility tree and the text of each, `steps.log`, `checks.txt`, logcat as `app.log`, `emulator.log`), `android-release-smoke-test`, `android-claude-test`, and the backend's logs; `android-app`, the APK for the Claude job, is kept a day. The job summaries list the checks and the Claude usage. + +## The emulator + +A Google APIs x86_64 system image of API 34 (revision 14) with KVM, on the x86_64 runner: the arm64 runner has no KVM, and Google publishes no Linux arm64 emulator. The image's ARM translation (`libndk_translation`) runs the app's arm64-v8a code, the only ABI besides armeabi-v7a the core is built for. `install-sdk.sh` installs the emulator, platform-tools and the image at pinned revisions from Google's repository, checking each archive's SHA-1 and SHA-256, and writes the AVD itself; `emulator.sh` boots it without a window. Screenshots come from the emulator console (`adb emu screenrecord screenshot`): without a window, the guest's `screencap` returns an empty or black image. + +## Scripts + +- `ui`: the UI driver, with desktop's command names: text and positions from uiautomator's accessibility tree, taps and keys with adb. `ui` alone prints its commands: start (with app settings), shot, text, tree, find, wait, click, click-text, scroll, type, key, layout (the `OneHandUI` preference and a restart), relaunch, text-size, do, stop. `screen-tree.py` reads the tree; `prefs.py` writes the settings, which only a debug build accepts (`run-as`). +- `smoke-test.sh [start|onboarding|layouts|backend]`: the scripted cases; the default is onboarding, a local group and both layouts. Onboarding on Android includes the app's instant notifications alert and the system's battery dialog. `backend` also adds the test SMP server under Your servers and checks that it passes Test servers. +- `install-sdk.sh`, `emulator.sh`: the emulator, above. +- `build-core.sh`, `install-core.sh`, `release-apk.sh`: builds and the release. +- `claude-test.md` (the prompt) and `claude-scope` (whether it uses the test backend): the Claude job, which runs desktop-linux's `claude-test.sh`, `claude-guard.py` and `claude-usage.py`. + +## The test backend + +Desktop-linux's `backend.sh` starts it on the runner as it does for desktop: [its README](../desktop-linux/README.md#the-test-backend) describes what it seeds. Its SMP server listens on the runner's 127.0.0.1:5223, and its addresses, the links peers create on it included, have 127.0.0.1, which in the emulator is the emulator itself: `emulator.sh reverse 5223` forwards the emulator's 127.0.0.1:5223 to the runner's (`adb reverse`) and waits until the server answers. The emulator's alias for the runner, 10.0.2.2, was unreachable on this image, and couldn't cover the addresses in links anyway. The app gets private routing to unknown servers only (`NetworkSMPProxyMode=Unknown`), since the preset proxies can't reach 127.0.0.1. The scripted backend case checks that the app reaches the server; the directory and SimpleX names aren't checked here. + +## The Claude job + +It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's APK in a new emulator, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too; it never reaches the emulator. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the other app workflows pin, and runs `claude-haiku-5-5` with desktop's harness: `--max-turns 60`, `--max-budget-usd 3`, Bash limited to single `ui` commands by `claude-guard.py`, file tools limited to the session's directory, and its output checked for the token before anything is printed or uploaded. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`. + +## Known limits + +- **The app runs under ARM translation**, Google's closed-source `libndk_translation`, not natively: it's slower than on a phone and can hit bugs that only translation has. In one of the debug build's first ten starts here, the app died at start with a SIGSEGV inside the translator (`ndk_translation_HandleNoExec`); the smoke test then fails at once and prints the crash. The slowed app also dropped the end of a long text typed at once, so `ui type` types a few characters at a time and checks each part on the field. Before calling a failure an app bug, run the workflow with `release` and check the released app in the same emulator. +- **The core build relies on cache.zw3rk.com**, the zw3rk Hydra's cache, for the cross-compiling toolchain and the dependencies: with it only simplex-chat itself is compiled. A change of the flake's inputs (`flake.lock`, the haskell.nix pin, the index state) that the cache doesn't hold yet, or the cache being down, turns the build into compiling GHC and every dependency, 12 to 24 hours, longer than a job may run. The core job then times out; a branch whose Haskell and Nix sources are unchanged keeps reusing its last build for 30 days. +- On a first start the app can show its What's new sheet over the first onboarding screen; the smoke test closes it and notes it in `checks.txt`. + +## Cost + +The runners are free for this public repository. A Claude run spends the subscription's usage limits, which interactive sessions share: Haiku 5.5 did this smoke path in 24 turns, 0.01 USD estimated at API prices (Claude Code's estimate, not a bill on a subscription). diff --git a/scripts/ci/android/build-core.sh b/scripts/ci/android/build-core.sh new file mode 100755 index 0000000000..36587ffe36 --- /dev/null +++ b/scripts/ci/android/build-core.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# Builds the arm64 Android core (libsimplex.so with the libraries it needs, and libsupport.so) with Nix, as +# scripts/android/build-android.sh does, and packs them as scripts/android/download-libs.sh lays them out. +# Usage: scripts/ci/android/build-core.sh +# Installs the Nix version that build-android.sh pins, in single-user mode, with the zw3rk Hydra's cache, which +# builds these jobs for master: without it, the toolchain and every dependency would be compiled too. + +set -euo pipefail + +nix_ver=nix-2.22.0 +nix_hash=4fed7db867186c01ce2a2077da4a6950ed16232efbf78d0cd19700cff80559f9 +jobs=(".#hydraJobs.x86_64-linux.aarch64-android:lib:simplex-chat" ".#hydraJobs.x86_64-linux.aarch64-android:lib:support") + +install_nix() { + local tmp + tmp=$(mktemp -d) + sudo mkdir -p /nix + sudo chown "$USER" /nix + df -h / + # the Nix sandbox needs user namespaces, which Ubuntu 24.04 restricts + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + curl --proto '=https' --tlsv1.2 -sSf -o "$tmp/install" "https://releases.nixos.org/nix/$nix_ver/install" + echo "$nix_hash $tmp/install" | sha256sum -c + mkdir -p ~/.config/nix + cat > ~/.config/nix/nix.conf << EOF +sandbox = true +max-jobs = auto +cores = 0 +experimental-features = nix-command flakes +substituters = https://cache.nixos.org https://cache.zw3rk.com https://cache.iog.io +trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= loony-tools:pr9m4BkM/5/eSTZlkQyRt57Jz7OMBxNSUiMC4FkcNfk= hydra.iohk.io:f/Ea+s+dFdN+3Y/G+FDgSq+a5NEWhJGzdjvKNGv0/EQ= +EOF + sh "$tmp/install" --no-daemon + # shellcheck disable=SC1091 + . "$HOME/.nix-profile/etc/profile.d/nix.sh" + nix --version +} + +mkdir -p "$(dirname "$1")" +out="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +install_nix +pkg=$(mktemp -d) +for job in "${jobs[@]}"; do + echo "::group::nix build $job" + started=$SECONDS + nix build -L --out-link "$RUNNER_TEMP/result" "$job" + echo "::endgroup::" + echo "$job built in $(((SECONDS - started) / 60)) min" + unzip -o "$RUNNER_TEMP"/result/pkg-*.zip -d "$pkg" +done +ls -la "$pkg" +tar -C "$pkg" -czf "$out" . +ls -la "$out" diff --git a/scripts/ci/android/claude-scope b/scripts/ci/android/claude-scope new file mode 100644 index 0000000000..ca4c2da0f9 --- /dev/null +++ b/scripts/ci/android/claude-scope @@ -0,0 +1,3 @@ +# What the Claude job needs for the session claude-test.md describes. +# backend: yes when the prompt uses the test backend; a run that asks for Claude must then start it too. +backend: no diff --git a/scripts/ci/android/claude-test.md b/scripts/ci/android/claude-test.md new file mode 100644 index 0000000000..ae16750545 --- /dev/null +++ b/scripts/ci/android/claude-test.md @@ -0,0 +1,54 @@ +# Test: onboarding and the chat list in the SimpleX Chat Android app + +You are testing the SimpleX Chat Android app, which runs in an Android emulator on this machine. Drive it only with the `ui` helper, through Bash, one command per call: `ui [args]`. Anything else is refused; don't look for ways around that, and if something blocks the test, report it. The commands are listed at the end. + +Your working directory is where your output goes. `ui shot ` saves `screens/NN-.png` and prints the text on screen, one element per line after its centre `x,y` in screen pixels. Read the PNG to see the screen (1080×2400). The text comes from the app's accessibility tree, so it is spelled exactly, but it can list an element that another screen covers: when the text and the picture disagree, trust the picture. "Above" means a smaller y. `ui tree` also lists text fields and buttons that have no text. + +## What is set up + +- The app is already running, on its first screen, with a new, empty database: never run `ui start` or `ui stop`. Begin with `ui shot start` to see it. +- `ui type` types into the field that has the focus: tap a field before typing into it. `ui key BACK` is Android's back button. +- Besides the chats, the chat list shows the app's own cards as list items until they are closed: "New 1-time link / Paste link / Scan" (once there is a conversation), "Bottom bar / Top bar", and "Invest in SimpleX Chat!". They are part of the list, not something drawn over it: leave them open. + +## The test + +**A. Onboarding.** Each screen waits for the one before it; wait for a text before clicking it. + +1. The first screen has "Get started": click it. +2. Click the field "Enter profile name…", type `citester`, and click "Create profile". +3. On "Your network", click "Continue". +4. The app explains instant notifications in an alert: click "Allow". Android then asks whether the app may always run in the background: click "Allow" again. +5. Click "Accept" on the conditions screen. +6. Wait up to 60 s for the chat list, which shows "Talk to someone" while there are no conversations. Take a shot. + +**B. A group, and the chat list with the toolbar at the bottom.** + +1. Click "Start new chat" (the round button in the bottom toolbar), then "Create group". The name field under "Enter group name:" has the focus: type `seeded notes` and click "Create group". Wait for "Invite members", which shows once the group exists. +2. Run `ui relaunch`: it restarts the app and waits for the chat list. Take a shot. +3. Check, noting the evidence (screenshot names, positions): + - "seeded notes" is listed; + - the search field "Search or paste SimpleX link" is in the bottom half of the screen (y over 1200), and "Add list" is just above it; + - nothing is cut off, overlapping or garbled. + +**Between the layouts:** run `ui layout top`: it restarts the app with the toolbar at the top. + +**C. The chat list, toolbar at the top.** Check, noting the evidence: + +- "seeded notes" is still listed; +- the search field is in the top half of the screen (y under 1200), and "Add list" is just below it; +- nothing is cut off, overlapping or garbled. "Start new chat" is now a floating button at the bottom right, drawn over whatever the list shows there: note what it covers, but that alone is not a failure. + +You have at most 60 turns, and the whole test fits in about 25 if you chain steps whose outcome you can predict with `ui do` and look at the screen after each chain. For example: + +- `ui do click-text "Get started" -- click-text "Enter profile name" 30 -- type citester -- shot profile` +- `ui do click-text "Create profile" -- wait Continue 60 -- shot your-network` +- `ui do click-text "Start new chat" -- click-text "Create group" 10 -- wait "Enter group name" -- shot new-group` +- `ui do relaunch -- shot chat-list-bottom` + +`ui do` stops at the first command that fails, so a failed chain tells you where it stopped. + +## Report + +Write `report.md` in your working directory: for each of A, B and C, each check you ran as PASS or FAIL with its evidence, and anything else unexpected. End your final message with one line: `CLAUDE-TEST: PASS` if every check you ran passed, otherwise `CLAUDE-TEST: FAIL` followed by the checks that failed. + +## The ui helper diff --git a/scripts/ci/android/emulator.sh b/scripts/ci/android/emulator.sh new file mode 100755 index 0000000000..3ac90173cb --- /dev/null +++ b/scripts/ci/android/emulator.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# Boots the AVD "ci" (install-sdk.sh) without a window, with KVM, and waits until Android has booted and its +# package manager answers; then turns animations off and keeps the screen on and unlocked. +# Usage: scripts/ci/android/emulator.sh start +# scripts/ci/android/emulator.sh reverse forwards the emulator's 127.0.0.1: to the runner's, +# and waits until the server there answers +# The emulator keeps running for later steps of the job. + +set -euo pipefail + +boot_secs=600 + +start() { + local log=$1/emulator.log started deadline missing=0 + mkdir -p "$1" + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + ls -l /dev/kvm + emulator -accel-check + + local flags=(-avd ci -no-window -no-audio -no-boot-anim -no-snapshot -no-metrics -gpu swiftshader_indirect -camera-back none -camera-front none -memory 4096 -cores 4) + echo "emulator ${flags[*]}" + started=$SECONDS + nohup emulator "${flags[@]}" < /dev/null > "$log" 2>&1 & + + deadline=$((SECONDS + boot_secs)) + until [ "$(adb shell getprop sys.boot_completed 2> /dev/null)" = 1 ]; do + # the launcher starts qemu with the same arguments; just after the start neither may be running yet + if pgrep -f -- '-avd ci' > /dev/null; then + missing=0 + else + missing=$((missing + 1)) + fi + if [ "$missing" -ge 5 ] || [ "$SECONDS" -ge "$deadline" ]; then + echo "error: Android did not boot in $((SECONDS - started)) s$([ "$missing" -ge 5 ] && echo ", the emulator is gone")" + cat "$log" + return 1 + fi + sleep 2 + done + echo "booted in $((SECONDS - started)) s" + until adb shell pm path android > /dev/null 2>&1; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "error: no package manager $((SECONDS - started)) s after the start" + return 1 + fi + sleep 2 + done + echo "package manager ready after $((SECONDS - started)) s" + + adb shell settings put global window_animation_scale 0 + adb shell settings put global transition_animation_scale 0 + adb shell settings put global animator_duration_scale 0 + adb shell svc power stayon true + adb shell wm dismiss-keyguard + adb shell getprop | grep -E 'ro.build.version.(sdk|release)|ro.product.cpu.abilist|native.bridge|ro.kernel.qemu' || true + adb shell wm size +} + +# The test SMP server speaks TLS, which answers a plain-text request with an alert: any bytes back mean the request +# reached it. The forward can take a while to answer after the boot. +reverse() { + local port=$1 deadline=$((SECONDS + 60)) bytes + if ! [[ "$port" =~ ^[0-9]+$ ]]; then + echo "error: \"$port\" is not a port" + return 1 + fi + adb reverse "tcp:$port" "tcp:$port" + adb reverse --list + while :; do + bytes=$(adb shell "printf 'GET / HTTP/1.0\r\n\r\n' | nc -w 5 127.0.0.1 $port | wc -c" || true) + bytes=${bytes//[!0-9]/} + echo "127.0.0.1:$port from the emulator: ${bytes:-no} bytes back" + [ "${bytes:-0}" -eq 0 ] || return 0 + if [ "$SECONDS" -ge "$deadline" ]; then + echo "error: the emulator does not reach the runner's 127.0.0.1:$port" + return 1 + fi + sleep 2 + done +} + +case "${1:-}" in + start) start "$2" ;; + reverse) reverse "$2" ;; + *) + echo "usage: $0 start | reverse " + exit 1 + ;; +esac diff --git a/scripts/ci/android/install-core.sh b/scripts/ci/android/install-core.sh new file mode 100755 index 0000000000..853e76bfba --- /dev/null +++ b/scripts/ci/android/install-core.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Unpacks the core package from build-core.sh where scripts/android/download-libs.sh puts the Hydra builds. +# Usage: scripts/ci/android/install-core.sh + +set -euo pipefail + +libs_dir=apps/multiplatform/common/src/commonMain/cpp/android/libs/arm64-v8a +mkdir -p "$libs_dir" +tar -C "$libs_dir" -xzf "$1" +ls -la "$libs_dir" +readelf -d "$libs_dir/libsimplex.so" | grep -E 'NEEDED|SONAME' diff --git a/scripts/ci/android/install-sdk.sh b/scripts/ci/android/install-sdk.sh new file mode 100755 index 0000000000..5e66479abb --- /dev/null +++ b/scripts/ci/android/install-sdk.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +# Installs a pinned emulator, platform-tools and the Google APIs x86_64 system image of API 34 from Google's repository, +# each checked against the SHA-1 its repository manifest lists and the SHA-256 recorded when it was pinned, and creates +# the AVD "ci" for that image. +# Usage: scripts/ci/android/install-sdk.sh +# Puts ANDROID_SDK_ROOT, ANDROID_HOME and ANDROID_AVD_HOME in GITHUB_ENV and the emulator and adb on GITHUB_PATH. + +set -euo pipefail + +mkdir -p "$1" +sdk=$(cd "$1" && pwd) +repo=https://dl.google.com/android/repository +api=34 + +install() { + local dest=$1 url=$2 sha1=$3 sha256=$4 zip + zip=$sdk/downloads/$(basename "$url") + mkdir -p "$sdk/downloads" + curl --proto '=https' --tlsv1.2 -sSfL --retry 3 -o "$zip" "$url" + echo "$sha1 $zip" | sha1sum -c + echo "$sha256 $zip" | sha256sum -c + mkdir -p "$dest" + unzip -q "$zip" -d "$dest" + rm "$zip" +} + +# emulator 37.2.12 and platform-tools 37.0.1, the stable channel on 2026-10-19 +install "$sdk" "$repo/emulator-linux_x64-16428233.zip" \ + cd7362ea55dfb86a418958138dc396e74165dd01 b08fc43d8608d2955f607f1b287beb525041e730086bdca3af152accac3af9c1 +install "$sdk" "$repo/platform-tools_r37.0.1-linux.zip" \ + 477254aa5f903c15cf51001717bdf347fb6b53e0 d230f13842f60f782a8645f9c813f8f845bf36089ea7289f28c48f17979313f1 +install "$sdk/system-images/android-$api/google_apis" "$repo/sys-img/google_apis/x86_64-34_r14.zip" \ + e0f6c9a0691aa27bd597d0deb1bcfdc943ac8ca7 783a40134baf4f3012d4464fbe1571b1612a0dbd2e7a44d14bd8328923443833 + +# avdmanager needs the package.xml that only sdkmanager writes +avd_home=$sdk/avd +mkdir -p "$avd_home/ci.avd" +cat > "$avd_home/ci.ini" << EOF +avd.ini.encoding=UTF-8 +path=$avd_home/ci.avd +target=android-$api +EOF +cat > "$avd_home/ci.avd/config.ini" << EOF +avd.ini.encoding=UTF-8 +AvdId=ci +abi.type=x86_64 +hw.cpu.arch=x86_64 +hw.cpu.ncore=4 +hw.ramSize=4096 +disk.dataPartition.size=6G +hw.lcd.width=1080 +hw.lcd.height=2400 +hw.lcd.density=420 +hw.keyboard=yes +hw.gpu.enabled=yes +hw.gpu.mode=swiftshader_indirect +image.sysdir.1=system-images/android-$api/google_apis/x86_64/ +tag.id=google_apis +PlayStore.enabled=false +EOF + +# the emulator's launcher finds its own libraries under lib64; only system libraries can be missing +if LD_LIBRARY_PATH=$(find "$sdk/emulator/lib64" -type d | paste -sd :) ldd "$sdk/emulator/qemu/linux-x86_64/qemu-system-x86_64" | grep 'not found'; then + echo "error: the emulator needs these libraries" + exit 1 +fi +"$sdk/emulator/emulator" -version | head -n 1 +"$sdk/platform-tools/adb" version | head -n 2 +grep -E '^(ro.build.version.sdk|ro.product.cpu.abilist|ro.system.product.cpu.abilist)=' "$sdk/system-images/android-$api/google_apis/x86_64/build.prop" || true +du -sh "$sdk" + +if [ -n "${GITHUB_ENV:-}" ]; then + { + echo "ANDROID_SDK_ROOT=$sdk" + echo "ANDROID_HOME=$sdk" + echo "ANDROID_AVD_HOME=$avd_home" + } >> "$GITHUB_ENV" + echo "$sdk/emulator" >> "$GITHUB_PATH" + echo "$sdk/platform-tools" >> "$GITHUB_PATH" +fi diff --git a/scripts/ci/android/prefs.py b/scripts/ci/android/prefs.py new file mode 100755 index 0000000000..72272d2dea --- /dev/null +++ b/scripts/ci/android/prefs.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 + +# Prints the app's SharedPreferences file with the settings given, typed as AppPreferences in SimpleXAPI.kt reads them. +# Usage: prefs.py =... + +import sys +import xml.etree.ElementTree as ET + +TYPES = {"OneHandUI": "boolean", "DeveloperTools": "boolean", "NetworkSMPProxyMode": "string", "LogLevel": "string"} + + +def main(): + path, settings = sys.argv[1], sys.argv[2:] + with open(path, "rb") as f: + data = f.read().strip() + prefs = ET.fromstring(data) if data else ET.Element("map") + for setting in settings: + name, _, value = setting.partition("=") + kind = TYPES.get(name) + if kind is None or (kind == "boolean" and value not in ("true", "false")): + known = ", ".join(f"{n}=<{t}>" for n, t in TYPES.items()) + sys.exit(f"error: the settings are {known}, not {setting}") + for old in prefs.findall(f"*[@name='{name}']"): + prefs.remove(old) + if kind == "boolean": + ET.SubElement(prefs, kind, name=name, value=value) + else: + ET.SubElement(prefs, kind, name=name).text = value + print("") + print(ET.tostring(prefs, encoding="unicode")) + + +main() diff --git a/scripts/ci/android/release-apk.sh b/scripts/ci/android/release-apk.sh new file mode 100755 index 0000000000..5a79b4f140 --- /dev/null +++ b/scripts/ci/android/release-apk.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Downloads the released arm64 APK at the branch's latest release tag and checks that SimpleX signed it. +# Usage: scripts/ci/android/release-apk.sh +# The APK is then /simplex-aarch64.apk. Needs the build tools of the runner's SDK, before install-sdk.sh replaces it. + +set -euo pipefail + +# the release signing certificate, as scripts/simplex-chat-reproduce-builds-android.sh checks it +simplex_key=3C:52:C4:FD:3C:AD:1C:07:C9:B0:0A:70:80:E3:58:FA:B9:FE:FC:B8:AF:5A:EC:14:77:65:F1:6D:0F:21:AD:85 +dir=$1 +tag=$(git describe --tags --abbrev=0 --match 'v*' --exclude '*-armv7a' --exclude '*-fdroid') +apk=$dir/simplex-aarch64.apk +echo "release $tag" + +mkdir -p "$dir" +curl -fsSL -o "$apk" "https://github.com/simplex-chat/simplex-chat/releases/download/$tag/simplex-aarch64.apk" +apksigner=$(find "$ANDROID_HOME/build-tools" -name apksigner | sort -V | tail -n 1) +signer=$("$apksigner" verify --print-certs "$apk" | sed -n 's/.*certificate SHA-256 digest: //p' | head -n 1 | fold -w2 | paste -sd: | tr '[:lower:]' '[:upper:]') +if [ "$signer" != "$simplex_key" ]; then + echo "error: $apk is signed by $signer, not SimpleX" + exit 1 +fi +echo "signed by SimpleX: $signer" +ls -la "$apk" diff --git a/scripts/ci/android/screen-tree.py b/scripts/ci/android/screen-tree.py new file mode 100755 index 0000000000..010e8b735a --- /dev/null +++ b/scripts/ci/android/screen-tree.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +# Reads a uiautomator dump of the screen: +# screen-tree.py lines the elements with a text or a content description, one per line: "x,y text", the element's centre +# screen-tree.py find "x y left top right bottom" of the element with the text, or exits 1 +# screen-tree.py tree the elements that have a text or can be used: class, text, bounds and states +# screen-tree.py focused [] exits 1 unless a text field has the focus, and shows the text exactly +# Text is compared as the desktop ui helper compares it: letters and digits only, any case. An element that is exactly +# the text wins over one that contains it, and then one inside a clickable element (Compose puts a button's text in a +# child of the clickable view) over a title with the same text. + +import json +import re +import sys +import xml.etree.ElementTree as ET + +BOUNDS = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]") +STATES = ["clickable", "checkable", "checked", "focused", "scrollable", "selected"] +FIELD = "android.widget.EditText" +# listed by tree even without a text: fields, and the progress indicators of screens that are busy +WIDGETS = [FIELD, "android.widget.ProgressBar"] + + +def normalise(text): + return "".join(c for c in text.lower() if c.isalnum()) + + +def label(node): + return " ".join((node.get("text") or node.get("content-desc") or "").split()) + + +# Elements off the screen, such as the chat list while a chat covers it, have empty bounds. +def nodes(node, in_clickable=False): + clickable = in_clickable or node.get("clickable") == "true" + bounds = BOUNDS.fullmatch(node.get("bounds", "")) + if bounds: + left, top, right, bottom = map(int, bounds.groups()) + if right > left and bottom > top: + yield node, clickable, (left, top, right, bottom) + for child in node: + yield from nodes(child, clickable) + + +def tree_line(node, bounds): + left, top, right, bottom = bounds + states = [s for s in STATES if node.get(s) == "true"] + (["disabled"] if node.get("enabled") == "false" else []) + kind = node.get("class", "").rsplit(".", 1)[-1] + return f"{kind} {json.dumps(label(node))} at {left},{top} size {right - left}x{bottom - top}{''.join(' ' + s for s in states)}" + + +def main(): + path, command = sys.argv[1], sys.argv[2] + found = list(nodes(ET.parse(path).getroot())) + labelled = [(label(n), clickable, bounds) for n, clickable, bounds in found if label(n)] + if command == "lines": + for text, _, (left, top, right, bottom) in labelled: + print(f"{(left + right) // 2},{(top + bottom) // 2} {text}") + elif command == "tree": + for node, _, bounds in found: + if label(node) or node.get("class") in WIDGETS or any(node.get(s) == "true" for s in ["clickable", "checkable", "scrollable"]): + print(tree_line(node, bounds)) + elif command == "focused": + text = " ".join(sys.argv[3].split()) if len(sys.argv) > 3 else "" + if not any(n.get("class") == FIELD and n.get("focused") == "true" and text in label(n) for n, _, _ in found): + sys.exit(1) + elif command == "find": + target = normalise(sys.argv[3]) + matches = [e for e in labelled if target and target in normalise(e[0])] + if not matches: + sys.exit(1) + _, _, (left, top, right, bottom) = min(matches, key=lambda e: (normalise(e[0]) != target, not e[1])) + print(f"{(left + right) // 2} {(top + bottom) // 2} {left} {top} {right} {bottom}") + else: + sys.exit(f"unknown command: {command}") + + +main() diff --git a/scripts/ci/android/smoke-test.sh b/scripts/ci/android/smoke-test.sh new file mode 100755 index 0000000000..61017047d1 --- /dev/null +++ b/scripts/ci/android/smoke-test.sh @@ -0,0 +1,303 @@ +#!/bin/bash + +# Installs the APK in the booted emulator (emulator.sh) and runs the scripted UI cases with the ui helper: onboarding to +# the chat list, a local group, then the chat list with the reachable toolbar (the default) and with the top bar. +# Usage: scripts/ci/android/smoke-test.sh [start|onboarding|layouts|backend] +# With "start" it stops at the first screen, for an agent to go on from; with "onboarding", at the chat list. "backend" +# also adds the test SMP server (NAMES_SERVER, from backend.sh, reached through emulator.sh reverse) under Your servers +# and tests it, before both layouts. +# Each step saves a numbered screenshot, the accessibility tree and the text on screen to the output dir, prints the +# text to the log, and adds a line to steps.log there; logcat is in app.log. What the screens show is checked without +# stopping: every check is listed in checks.txt and the job summary, and the test fails at the end if any failed. +# The app keeps running for later steps of the job, which get UI_OUT. + +set -euo pipefail + +apk=$1 +export UI_OUT=$2 +cases=${3:-layouts} +case "$cases" in + start | onboarding | layouts | backend) ;; + *) + echo "error: unknown case \"$cases\": start, onboarding, layouts (the default) or backend" + exit 1 + ;; +esac +package=chat.simplex.app +local_chat="seeded notes" +search_field="Search or paste SimpleX link" +ui=$(dirname "$0")/ui +checks=$UI_OUT/checks.txt +mkdir -p "$UI_OUT" +: > "$checks" +if [ -n "${GITHUB_ENV:-}" ]; then + echo "UI_OUT=$UI_OUT" >> "$GITHUB_ENV" +fi + +report_checks() { + [ -s "$checks" ] || return 0 + echo "::group::checks" + cat "$checks" + echo "::endgroup::" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### Smoke test checks" + echo + sed 's/^/- /' "$checks" + } >> "$GITHUB_STEP_SUMMARY" + fi +} + +finish() { + local status=$? + if [ "$status" -ne 0 ]; then + "$ui" shot final || true + fi + echo "::group::the core and crashes in logcat" + grep -E 'ndk_translation|SIMPLEX *: (start|App crashed)|AndroidRuntime: (FATAL EXCEPTION|Process: )|Fatal signal' "$UI_OUT/app.log" | head -n 100 || true + echo "::endgroup::" + report_checks + exit "$status" +} +trap finish EXIT + +fail() { + echo "error: $*" + exit 1 +} + +check() { + local what=$1 + shift + if "$@"; then + echo "PASS $what" | tee -a "$checks" + else + echo "FAIL $what" | tee -a "$checks" + echo "::error::$what" + fi +} + +# A crash: the app logs its own under SIMPLEX ("App crashed"), Android's FATAL EXCEPTION covers one before the app's +# handler is set, and a native fatal signal names the process by the last 15 characters of its name. Not any line +# with "error" or from AndroidRuntime, which logs every uiautomator dump. +app_running() { + ! grep -q 'SIMPLEX *: App crashed' "$UI_OUT/app.log" && + ! grep -A 1 'FATAL EXCEPTION' "$UI_OUT/app.log" | grep -q "Process: $package," && + ! grep -q "Fatal signal .*(${package: -15})" "$UI_OUT/app.log" && + adb shell pidof "$package" > /dev/null +} + +y_of() { + local pos + pos=$("$ui" find "$1") || return 1 + cut -d ' ' -f 2 <<< "$pos" +} + +above() { + local a b + a=$(y_of "$1") || return 1 + b=$(y_of "$2") || return 1 + [ "$a" -lt "$b" ] +} + +screen_height() { + adb shell wm size | sed -n 's/^Physical size: [0-9]*x//p' +} + +# Back closes one screen at a time, here between the screens of Settings, which cover the chat list without removing it +# from the tree: a restart is what returns to the chat list. +back_to() { + for _ in 1 2 3 4 5; do + "$ui" find "$1" > /dev/null 2>&1 && return 0 + "$ui" key BACK + done + "$ui" find "$1" > /dev/null +} + +# On a first start the chat list can show for a moment before onboarding, long enough to open its What's new sheet over +# the first screen: it is closed and noted, not failed. +first_screen() { + local deadline=$((SECONDS + 180)) + until "$ui" find "Get started" > /dev/null 2>&1; do + app_running || return 1 + if "$ui" find "New in" > /dev/null 2>&1; then + echo "NOTE the What's new sheet covered the first onboarding screen" | tee -a "$checks" + "$ui" shot whats-new-over-onboarding + "$ui" click-text OK + fi + [ "$SECONDS" -lt "$deadline" ] || return 1 + sleep 1 + done +} + +# After Your network Android asks about instant notifications, in front of the conditions: the app's alert, then the +# system's battery dialog, each with "Allow". The conditions show for a moment between the two. +allow_notifications_and_accept() { + local deadline=$((SECONDS + 120)) + until "$ui" find "Talk to someone" > /dev/null 2>&1; do + if "$ui" find "Allow" > /dev/null 2>&1; then + "$ui" shot notifications + "$ui" click-text "Allow" + elif "$ui" find "Accept" > /dev/null 2>&1; then + "$ui" shot network-commitments + "$ui" click-text "Accept" + fi + [ "$SECONDS" -lt "$deadline" ] || return 1 + sleep 1 + done +} + +# The centre "x y" of the first text field below the text, which may have neither a text nor a placeholder. +field_below() { + local y + y=$(y_of "$1") || return 1 + "$ui" tree | sed -n -E 's/^EditText .* at ([0-9]+),([0-9]+) size ([0-9]+)x([0-9]+).*/\1 \2 \3 \4/p' | + awk -v y="$y" '$2 > y { print $1 + int($3 / 2), $2 + int($4 / 2); exit }' +} + +# The centre "x y" of the switch in the row of the text. +switch_of() { + local y + y=$(y_of "$1") || return 1 + "$ui" tree | sed -n -E 's/.* at ([0-9]+),([0-9]+) size ([0-9]+)x([0-9]+) .*checkable.*/\1 \2 \3 \4/p' | + awk -v y="$y" '$2 <= y && y <= $2 + $4 { print $1 + int($3 / 2), $2 + int($4 / 2); exit }' +} + +# The user picker's button in the toolbar, before the list has chats: chats show the same profile image placeholder. +open_settings() { + "$ui" click-text "profile image placeholder" 10 + "$ui" click-text "Settings" 10 +} + +# Green pixels of the test status left of a server's host name: SimplexGreen, the tick of a server that passed its test. +green_before() { + local x y l t r b png + read -r x y l t r b < <("$ui" find "$1") || return 1 + "$ui" shot "$2" > /dev/null || return 1 + png=$(find "$UI_OUT" -maxdepth 1 -name "*-$2.png" | sort | tail -n 1) + convert "$png" -alpha off -crop "${l}x$((b - t))+0+$t" +repage -fuzz 20% -fill black +opaque 'rgb(77,218,103)' \ + -fill white -opaque 'rgb(77,218,103)' -colorspace Gray -format '%[fx:round(mean*w*h)]' info: +} + +# Waits up to 60 s for Test servers to finish: the test status turns green, or the app shows that the test failed. +server_tested() { + local deadline=$((SECONDS + 60)) green + while [ "$SECONDS" -lt "$deadline" ]; do + if "$ui" find "Server test failed" > /dev/null 2>&1; then + "$ui" shot server-test-failed + return 1 + fi + green=$(green_before 127.0.0.1 server-test) || return 1 + echo "green pixels before the test server's host: $green" + if [ "$green" -ge 50 ]; then + return 0 + fi + sleep 2 + done + return 1 +} + +# Settings > Network & servers > Your servers: the test SMP server added, tested and saved. Its address has 127.0.0.1, +# which the emulator forwards to the runner's. +test_server() { + local x y + open_settings + "$ui" click-text "Network & servers" 10 + "$ui" click-text "Your servers" 10 + "$ui" click-text "Add server" 10 + "$ui" click-text "Enter server manually" 10 + "$ui" wait "Your server address" 10 + read -r x y < <(field_below "Your server address") || fail "no address field for the new server" + "$ui" click "$x" "$y" + "$ui" type "$NAMES_SERVER" + # a server entered by hand is off, and Test servers tests only the servers that are on + read -r x y < <(switch_of "Use for new connections") || fail "no \"Use for new connections\" switch" + "$ui" click "$x" "$y" + "$ui" shot new-server + back_to "Test servers" || fail "no Your servers after adding the server" + "$ui" wait 127.0.0.1 10 + "$ui" click-text "Test servers" + check "the test SMP server passes Test servers" server_tested + back_to "Save servers" || fail "no Network & servers after Your servers" + "$ui" click-text "Save servers" + "$ui" shot servers-saved + "$ui" relaunch +} + +# A group of the user's own: without chats the chat list shows the onboarding pages, not the search field. The new +# group's name field has the focus. +create_local_chat() { + "$ui" click-text "Start new chat" 10 + "$ui" click-text "Create group" 10 + "$ui" wait "Enter group name" 10 + "$ui" type "$local_chat" + "$ui" wait "$local_chat" 10 + "$ui" shot new-group + "$ui" click-text "Create group" 10 + # the app offers to invite members once it has created the group; without contacts it has no "Skip inviting members" + "$ui" wait "Invite members" 30 + "$ui" shot group-created + "$ui" relaunch + "$ui" wait "$search_field" 30 + "$ui" shot local-chat +} + +# The search field and "Add list" next to it: at the bottom of the chat list, "Add list" above the field; at the top, below it. +check_layout() { + local layout=$1 field half + half=$(($(screen_height) / 2)) + field=$(y_of "$search_field") || field="" + "$ui" shot "$layout-layout" + check "$layout: \"$local_chat\" listed" "$ui" find "$local_chat" + if [ "$layout" = reachable ]; then + check "reachable: the search field is in the bottom half" [ "${field:-0}" -gt "$half" ] + check "reachable: \"Add list\" above the search field" above "Add list" "$search_field" + else + check "top: the search field is in the top half" [ "${field:-$((half * 2))}" -lt "$half" ] + check "top: \"Add list\" below the search field" above "$search_field" "Add list" + fi +} + +# private routing to unknown servers only: the preset servers that would proxy to the test SMP server can't reach the +# runner's 127.0.0.1; developer tools at the debug level put the app's debug log in logcat +started=$SECONDS +"$ui" start "$apk" NetworkSMPProxyMode=Unknown DeveloperTools=true LogLevel=DEBUG +first_screen || fail "no first onboarding screen 180 s after the start, or the app stopped: its crashes in logcat are below" +echo "first screen after $((SECONDS - started)) s" +"$ui" shot first-screen +if [ "$cases" = start ]; then + echo "smoke test: the app shows its first screen" + exit 0 +fi + +"$ui" click-text "Get started" +"$ui" click-text "Enter profile name" 30 +"$ui" type citester +"$ui" wait citester 10 +"$ui" shot profile-name +"$ui" click-text "Create profile" + +"$ui" wait "Continue" 60 +"$ui" shot your-network +"$ui" click-text "Continue" + +allow_notifications_and_accept || fail "no chat list 120 s after Your network" +"$ui" shot chat-list +echo "onboarding reached the chat list $((SECONDS - started)) s after the start" + +if [ "$cases" != onboarding ]; then + if [ "$cases" = backend ]; then + : "${NAMES_SERVER:?NAMES_SERVER must be the test SMP server address}" + test_server + fi + create_local_chat + check_layout reachable + "$ui" layout top + check_layout top +fi +check "the app is running and logged no crash" app_running + +if grep -q '^FAIL' "$checks"; then + fail "the smoke test failed checks: $(grep -c '^FAIL' "$checks") of $(grep -cE '^(PASS|FAIL)' "$checks")" +fi +echo "smoke test passed: $cases" diff --git a/scripts/ci/android/ui b/scripts/ci/android/ui new file mode 100755 index 0000000000..5601c91299 --- /dev/null +++ b/scripts/ci/android/ui @@ -0,0 +1,341 @@ +#!/bin/bash + +# Drives the Android app in the emulator with adb, for the scripted cases and for an agent on the runner. +# Text and positions come from uiautomator's accessibility tree; coordinates are screen pixels, as in the screenshots. +# Screenshots, trees and a log of every command (steps.log) go to UI_OUT, and logcat to UI_OUT/app.log. +# +# ui start [=...] +# install the app, give a debug build the settings (OneHandUI, NetworkSMPProxyMode, +# DeveloperTools, LogLevel), and start it with logcat +# ui shot save UI_OUT/NN-.png, NN-.xml (the tree) and NN-.txt, and print the text on screen +# ui text print the text on screen, one element per line after its centre x,y +# ui tree print the elements that have a text or can be used: class, text, bounds and states +# ui find print "x y left top right bottom", the element's centre and bounds; fail if it is not on screen +# ui wait [secs] wait up to secs (30) until the text is on screen +# ui click tap at x,y +# ui click-text [secs] wait up to secs (20) for the text and tap its centre +# ui scroll [times] +# swipe at x,y to scroll towards the top (up) or bottom (down) of the content, 3 times unless given +# ui type type ASCII text into the focused field, waiting up to 10 s for a field to have the focus; +# fail unless the field then shows the text +# ui key ... press keys by their Android names without KEYCODE_ (BACK, ENTER, DEL) +# ui layout restart the app with the reachable toolbar (the one-hand UI, the default) or the top bar +# ui relaunch restart the app and wait for the chat list +# ui text-size set the system font scale: 1 is the default, 1.3 and 2 are larger +# ui do [args] -- [args] ... +# run the commands in order, stopping at the first that fails +# ui stop stop the app and logcat +# +# Text is compared ignoring case, spaces and punctuation; an element that is exactly the text wins over one that contains it. + +set -euo pipefail +# an agent on the runner runs this, and nothing it starts needs the agent's credentials +unset CLAUDE_CODE_OAUTH_TOKEN + +: "${UI_OUT:?UI_OUT must be the output directory}" +# Claude Code's Bash tool builds its own PATH, without the SDK that install-sdk.sh put on the job's +PATH=${ANDROID_HOME:?ANDROID_HOME must be the SDK from install-sdk.sh}/platform-tools:$PATH +package=chat.simplex.app +prefs_file=$package.SIMPLEX_APP_PREFS.xml +# the button that opens New chat, in the toolbar or floating, whenever the chat list shows +chat_list_button="Start new chat" +state=${UI_STATE:-${RUNNER_TEMP:-/tmp}/ui} +here=$(cd "$(dirname "$0")" && pwd) +mkdir -p "$UI_OUT" "$state" + +log() { + echo "$(date -u +%H:%M:%S) $*" | tee -a "$UI_OUT/steps.log" +} + +number() { + if ! [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + echo "error: \"$1\" is not a number" + exit 1 + fi +} + +# uiautomator fails while the screen is changing ("could not get idle state"), so a dump is retried +dump() { + local attempt + for attempt in 1 2 3 4 5; do + adb shell rm -f /sdcard/ui.xml + if adb shell uiautomator dump /sdcard/ui.xml > /dev/null 2>&1 && adb exec-out cat /sdcard/ui.xml > "$state/ui.xml" && grep -q '&2 + return 1 +} + +screen() { + dump + python3 "$here/screen-tree.py" "$state/ui.xml" "$@" +} + +wait_for() { + local text=$1 deadline=$((SECONDS + $2)) + until screen find "$text" 2> /dev/null; do + [ "$SECONDS" -lt "$deadline" ] || return 1 + sleep 1 + done +} + +next_file() { + local n + n=$(find "$UI_OUT" -maxdepth 1 -name '[0-9][0-9]-*.png' | wc -l) + printf '%s/%02d-%s' "$UI_OUT" $((n + 1)) "$1" +} + +screenshot() { + local dir=$state/screenshot png + rm -rf "$dir" + mkdir -p "$dir" + # without a window the emulator composes the screen on the host, where the guest's screencap can't read it + adb emu screenrecord screenshot "$dir" > /dev/null + for _ in $(seq 20); do + png=$(find "$dir" -name '*.png' -size +0 | head -n 1) + if [ -n "$png" ]; then + mv "$png" "$1" + return 0 + fi + sleep 0.5 + done + echo "error: no screenshot from the emulator" + return 1 +} + +shot() { + local f + if ! [[ "$1" =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "error: a shot name has only letters, digits, - and _" + return 1 + fi + f=$(next_file "$1") + screenshot "$f.png" + dump + cp "$state/ui.xml" "$f.xml" + python3 "$here/screen-tree.py" "$f.xml" lines > "$f.txt" + echo "::group::text on screen: $(basename "$f")" + cat "$f.txt" + echo "::endgroup::" + log "shot $(basename "$f").png" +} + +launch() { + local activity + activity=$(adb shell cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER "$package" | tail -n 1) + adb shell am start -W -n "$activity" +} + +# A debug build lets run-as rewrite its preferences while the app is stopped; the file is read back to check the write. +set_prefs() { + adb shell am force-stop "$package" + adb shell "run-as $package cat shared_prefs/$prefs_file 2> /dev/null" > "$state/prefs.xml" || true + python3 "$here/prefs.py" "$state/prefs.xml" "$@" > "$state/prefs-new.xml" + adb shell "run-as $package sh -c 'mkdir -p shared_prefs && cat > shared_prefs/$prefs_file'" < "$state/prefs-new.xml" + if ! adb shell "run-as $package cat shared_prefs/$prefs_file" | cmp -s - "$state/prefs-new.xml"; then + echo "error: the app's preferences are not what was written" + return 1 + fi +} + +start() { + local apk=$1 started=$SECONDS + shift + adb install -r -t -g "$apk" + log "installed in $((SECONDS - started)) s" + if [ $# -gt 0 ]; then + # a release build isn't debuggable, so run-as can't reach its preferences + if adb shell run-as "$package" true 2> /dev/null; then + set_prefs "$@" + else + log "start: $package is not debuggable, so it keeps its default settings, not $*" + fi + fi + adb logcat -c + setsid adb logcat -v threadtime < /dev/null >> "$UI_OUT/app.log" 2>&1 & + echo $! > "$state/logcat.pid" + launch +} + +relaunch() { + adb shell am force-stop "$package" + launch + if ! wait_for "$chat_list_button" 90 > /dev/null; then + echo "error: no chat list 90 s after the restart" + return 1 + fi +} + +layout() { + local one_hand + case "$1" in + reachable) one_hand=true ;; + top) one_hand=false ;; + *) + echo "error: layout is reachable or top" + return 1 + ;; + esac + set_prefs "OneHandUI=$one_hand" + relaunch +} + +# The app, slowed by the ARM translation, drops characters typed faster than it takes them, so the text is typed a few +# characters at a time, each part checked on the field. Each part goes to the device's shell as one single-quoted word, +# so neither shell interprets it; input text reads %s as a space. +type_text() { + local text=$1 typed="" part deadline=$((SECONDS + 10)) + until screen focused; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "error: no text field has the focus" + return 1 + fi + sleep 1 + done + while [ -n "$text" ]; do + part=${text:0:8} + text=${text:8} + typed+=$part + part=${part// /%s} + adb shell "input text '${part//\'/\'\\\'\'}'" + deadline=$((SECONDS + 10)) + until screen focused "$typed"; do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "error: the field doesn't show \"$typed\"" + return 1 + fi + sleep 1 + done + done +} + +scroll() { + local x=$1 y=$2 times=$4 to i + number "$x" + number "$y" + number "$times" + case "$3" in + up) to=$((y + 600)) ;; + down) to=$((y - 600)) ;; + *) + echo "error: scroll up or down, not \"$3\"" + return 1 + ;; + esac + for ((i = 0; i < times; i++)); do + adb shell input swipe "$x" "$y" "$x" "$to" 300 + done +} + +key() { + local k + for k in "$@"; do + if ! [[ "$k" =~ ^[A-Z0-9_]+$ ]]; then + echo "error: \"$k\" is not a key name such as BACK or ENTER" + return 1 + fi + adb shell input keyevent "KEYCODE_$k" + done +} + +stop() { + adb shell am force-stop "$package" + if [ -f "$state/logcat.pid" ]; then + kill "$(cat "$state/logcat.pid")" 2> /dev/null || true + rm -f "$state/logcat.pid" + fi +} + +case "${1:-}" in + start) + shift + log "start $*" + start "$@" + ;; + shot) shot "$2" ;; + text) screen lines ;; + tree) screen tree ;; + find) + if ! pos=$(screen find "$2"); then + log "find \"$2\": not on screen" >&2 + exit 1 + fi + log "find \"$2\": $pos" > /dev/null + echo "$pos" + ;; + wait) + secs=${3:-30} + number "$secs" + if ! pos=$(wait_for "$2" "$secs"); then + log "wait \"$2\": not on screen after $secs s" + exit 1 + fi + read -r x y _ <<< "$pos" + log "wait \"$2\": at $x,$y" + ;; + click) + number "$2" + number "$3" + adb shell input tap "$2" "$3" + log "click $2,$3" + ;; + click-text) + secs=${3:-20} + number "$secs" + if ! pos=$(wait_for "$2" "$secs"); then + log "click-text \"$2\": not on screen after $secs s" + exit 1 + fi + read -r x y _ <<< "$pos" + adb shell input tap "$x" "$y" + log "click-text \"$2\" at $x,$y" + ;; + scroll) + scroll "$2" "$3" "$4" "${5:-3}" + log "scroll $4 at $2,$3, ${5:-3} times" + ;; + type) + type_text "$2" + log "type \"$2\"" + ;; + key) + shift + key "$@" + log "key $*" + ;; + layout) + layout "$2" + log "layout $2" + ;; + relaunch) + relaunch + log "relaunch" + ;; + text-size) + number "$2" + adb shell settings put system font_scale "$2" + log "text-size $2" + ;; + do) + shift + command=() + for arg in "$@" --; do + if [ "$arg" != -- ]; then + command+=("$arg") + elif [ ${#command[@]} -gt 0 ]; then + "$0" "${command[@]}" + command=() + fi + done + ;; + stop) + stop + log "stop" + ;; + *) + sed -n '3,/^$/s/^# \{0,1\}//p' "$0" + exit 1 + ;; +esac diff --git a/scripts/ci/desktop-linux/README.md b/scripts/ci/desktop-linux/README.md index be0586558a..2363068a63 100644 --- a/scripts/ci/desktop-linux/README.md +++ b/scripts/ci/desktop-linux/README.md @@ -4,7 +4,7 @@ ## Running it -The workflow runs only when asked: by a run tag in a commit pushed to an `f/…` branch, or by hand from the Actions tab on any branch. [App tests on GitHub's runners](../README.md) lists the tags and inputs. +The workflow runs only when started by hand, on any branch, from the Actions tab or with `gh workflow run`: [App tests on GitHub's runners](../README.md) says how and lists the inputs. Every run builds the core library, and the backend when it's asked for, from the branch's own Haskell code. It reuses an earlier build only when the sources are unchanged, as an artifact of the same branch named after a hash of its sources (30 days): for the core `src/**`, `cabal.project`, `simplex-chat.cabal`, `PRIVACY.md` and the build scripts, for the backend `src/**`, the CLI's and the directory's sources, `cabal.project`, `simplex-chat.cabal` and its build script. So a branch that changes core and app code together tests both. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`. diff --git a/scripts/ci/desktop-linux/claude-test.sh b/scripts/ci/desktop-linux/claude-test.sh index 12fde73a05..6f70e6400f 100755 --- a/scripts/ci/desktop-linux/claude-test.sh +++ b/scripts/ci/desktop-linux/claude-test.sh @@ -1,8 +1,8 @@ #!/bin/bash -# Runs Claude Code to repeat the smoke test with the ui helper, as claude-test.md describes, and passes when -# its final message ends with "CLAUDE-TEST: PASS". -# Usage: scripts/ci/desktop-linux/claude-test.sh +# Runs Claude Code to repeat the smoke test with the ui helper, as the prompt (claude-test.md unless given) describes, +# and passes when its final message ends with "CLAUDE-TEST: PASS". +# Usage: scripts/ci/desktop-linux/claude-test.sh [prompt .md] # Expects the app started (smoke-test.sh ... start) with UI_OUT under , claude and ui on PATH, # CLAUDE_MODEL and CLAUDE_CODE_OAUTH_TOKEN. Claude works in : the screenshots, its report.md and the # prompt it was given end up there. The transcript, Claude Code's stderr (.stderr) and the guard hook's @@ -17,16 +17,17 @@ unset CLAUDE_CODE_OAUTH_TOKEN here=$(cd "$(dirname "$0")" && pwd) out=$(cd "$1" && pwd) transcript=$2 +prompt=${3:-$here/claude-test.md} stderr=$transcript.stderr settings=$(mktemp) export CLAUDE_GUARD_LOG=$transcript.guard.log : > "$CLAUDE_GUARD_LOG" { - cat "$here/claude-test.md" + cat "$prompt" echo echo '```' - "$here/ui" || true + ui || true echo '```' } > "$out/prompt.md" diff --git a/scripts/ci/ios-sim/README.md b/scripts/ci/ios-sim/README.md index 2e98b029a3..f72c6cb0b2 100644 --- a/scripts/ci/ios-sim/README.md +++ b/scripts/ci/ios-sim/README.md @@ -4,7 +4,7 @@ ## Running it -The workflow runs only when asked: by a run tag in a commit pushed to an `f/…` branch, or by hand from the Actions tab on any branch. [App tests on GitHub's runners](../README.md) lists the tags and inputs. +The workflow runs only when started by hand, on any branch, from the Actions tab or with `gh workflow run`: [App tests on GitHub's runners](../README.md) says how and lists the inputs. Results: the job logs, the Claude job's summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log), `ios-sim-backend-logs` and `ios-sim-claude-test` (the Claude job's smoke test in `smoke-test/`, and per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. @@ -40,7 +40,7 @@ Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; ## The test backend -`backend.sh start ` (with `[ci-backend]` or the `backend` input) starts, on the runner, against the preset SMP and XFTP servers: +`backend.sh start ` (with the `backend` input) starts, on the runner, against the preset SMP and XFTP servers: - a chat relay for channels, and a SimpleX Directory whose address it writes to `/directory-link`; - a CLI peer `ci_admin` that creates, registers and gets approved groups and channels with the profile JSON the app sends: `ci_group` and `ci_channel` ("ci seed"), `ci_cards`, six entries with app-size avatars made by `avatar.swift` ("ci avatar"), a long name, markdown in a description, and `cinews`, `cinews_digest`, `cinews_weekly`;