diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml new file mode 100644 index 0000000000..46e315ed7e --- /dev/null +++ b/.github/workflows/ui-tests.yml @@ -0,0 +1,431 @@ +name: ui-tests + +on: + workflow_dispatch: + inputs: + platform: + description: Which app's UI tests to run + type: choice + options: + - desktop + - android + - both + default: desktop + tests: + description: >- + Only these tests, comma- or space-separated: class names (chat.simplex.app.ui.main.ChatListRowsTest) or + Class#method; desktop also takes Gradle patterns with *. Empty runs all, in four shards per platform + type: string + default: '' + +permissions: + contents: read + actions: read + +concurrency: + group: ui-tests-${{ github.ref }} + cancel-in-progress: true + +env: + GHC_VER: 9.6.3 + CABAL_VER: 3.10.2.0 + JAVA_VER: 17 + DESKTOP_CORE_PKG: desktop-core-linux-x86_64.tar.gz + ANDROID_CORE_PKG: android-core-arm64-v8a.tar.gz + BIN_PKG: ui-tests-bin-linux-x86_64.tar.gz + APK: android-foss-arm64-v8a-debug.apk + +jobs: + plan: + name: choose jobs, find earlier builds + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + run-desktop: ${{ steps.jobs.outputs.desktop }} + run-android: ${{ steps.jobs.outputs.android }} + desktop-shards: ${{ steps.jobs.outputs.desktop-shards }} + android-shards: ${{ steps.jobs.outputs.android-shards }} + desktop-core: ${{ steps.names.outputs.desktop-core }} + desktop-core-run: ${{ steps.desktop-core.outputs.run-id }} + android-core: ${{ steps.names.outputs.android-core }} + android-core-run: ${{ steps.android-core.outputs.run-id }} + bin: ${{ steps.names.outputs.bin }} + bin-run: ${{ steps.bin.outputs.run-id }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Choose jobs + id: jobs + env: + PLATFORM: ${{ inputs.platform }} + TESTS: ${{ inputs.tests }} + run: scripts/ci/ui-tests/plan.sh + + # artifacts are named after the sources they are built from, so a run uses one that an earlier run uploaded; + # the core libraries have desktop-linux's and android-emu's names and sources, so these workflows reuse each other's + - name: Artifact names + id: names + run: | + echo "desktop-core=desktop-linux-core-${{ hashFiles('src/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/desktop/build-lib-linux.sh', 'scripts/ci/desktop-linux/build-core.sh') }}" >> "$GITHUB_OUTPUT" + echo "android-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 "bin=ui-tests-bin-${{ hashFiles('src/**', 'apps/simplex-chat/**', 'tests/UITestServers.hs', 'tests/ChatClient.hs', 'tests/NameResolver.hs', 'tests/ChatTests/DBUtils.hs', 'tests/ChatTests/DBUtils/**', 'cabal.project', 'simplex-chat.cabal', 'PRIVACY.md', 'scripts/ci/ui-tests/build-bin.sh') }}" >> "$GITHUB_OUTPUT" + + - name: Find desktop core library + id: desktop-core + if: steps.jobs.outputs.desktop == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.desktop-core }}" + + - name: Find Android core library + id: android-core + if: steps.jobs.outputs.android == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.android-core }}" + + - name: Find CLI and test servers + id: bin + env: + GH_TOKEN: ${{ github.token }} + run: scripts/ci/desktop-linux/earlier-run.sh "${{ steps.names.outputs.bin }}" + + desktop-core: + name: core library for Linux + needs: plan + if: needs.plan.outputs.run-desktop == 'true' && needs.plan.outputs.desktop-core-run == '' + runs-on: ubuntu-24.04 + timeout-minutes: 150 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Show runner + run: | + lsb_release -ds + nproc + free -h + df -h / + + - name: Setup Haskell + uses: simplex-chat/setup-haskell-action@v2 + with: + ghc-version: ${{ env.GHC_VER }} + cabal-version: ${{ env.CABAL_VER }} + + - name: Install libraries + run: | + sudo apt-get update + sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev patchelf + + - name: Build core library + run: scripts/ci/desktop-linux/build-core.sh ~/core/${{ env.DESKTOP_CORE_PKG }} + + - name: Upload core library + uses: actions/upload-artifact@v7 + with: + name: ${{ needs.plan.outputs.desktop-core }} + path: ~/core/${{ env.DESKTOP_CORE_PKG }} + compression-level: 0 + retention-days: 30 + if-no-files-found: error + + android-core: + name: core library for Android arm64 + needs: plan + if: needs.plan.outputs.run-android == 'true' && needs.plan.outputs.android-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.ANDROID_CORE_PKG }} + + - name: Upload core library + uses: actions/upload-artifact@v7 + with: + name: ${{ needs.plan.outputs.android-core }} + path: ~/core/${{ env.ANDROID_CORE_PKG }} + compression-level: 0 + retention-days: 30 + if-no-files-found: error + + bin: + name: CLI and test servers + needs: plan + if: needs.plan.outputs.bin-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 CLI and test servers + run: scripts/ci/ui-tests/build-bin.sh ~/bin/${{ env.BIN_PKG }} + + - name: Upload CLI and test servers + uses: actions/upload-artifact@v7 + with: + name: ${{ needs.plan.outputs.bin }} + path: ~/bin/${{ env.BIN_PKG }} + compression-level: 0 + retention-days: 30 + if-no-files-found: error + + desktop: + name: desktop UI tests (${{ matrix.shard }}) + needs: [plan, desktop-core, bin] + # the builds are skipped when an earlier run built them + if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' && needs.plan.outputs.run-desktop == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 150 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.desktop-shards) }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Show runner + run: | + lsb_release -ds + nproc + free -h + df -h / + sysctl kernel.apparmor_restrict_unprivileged_userns || true + + # the CLI's and test servers' libraries; the tests render headless, with these fonts + - name: Install libraries + run: | + sudo apt-get update + sudo apt-get install -y libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev fonts-dejavu-core + dpkg-query -W libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev fonts-dejavu-core + + # uitest.sh runs the tests in their own user, PID and network namespaces, which Ubuntu 24.04 restricts + - name: Allow user namespaces + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + unshare -rn --pid --fork --kill-child --mount-proc sh -c 'ip link set lo up && ip -brief addr' + + - name: Download core library + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.desktop-core }} + path: ~/core + run-id: ${{ needs.plan.outputs.desktop-core-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Install core library + run: scripts/ci/desktop-linux/install-core.sh ~/core/${{ env.DESKTOP_CORE_PKG }} + + - name: Download CLI and test servers + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.bin }} + path: ~/bin + run-id: ${{ needs.plan.outputs.bin-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Install CLI and test servers + run: scripts/ci/ui-tests/install-bin.sh ~/bin/${{ env.BIN_PKG }} "$RUNNER_TEMP/bin" + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: corretto + java-version: ${{ env.JAVA_VER }} + + # uitest.sh builds the native library and the tests, then runs them with as many test JVMs as memory allows + - name: Run UI tests + id: run + working-directory: apps/multiplatform + env: + TESTS: ${{ matrix.tests }} + run: | + read -r -a patterns <<< "$TESTS" + args=() + for p in "${patterns[@]}"; do + args+=(--tests "$p") + done + scripts/uitest.sh "${args[@]}" + + - name: Report UI tests + if: ${{ !cancelled() && steps.run.outcome != 'skipped' }} + env: + SHARD: ${{ matrix.shard }} + run: | + echo "## Desktop UI tests, shard $SHARD" >> "$GITHUB_STEP_SUMMARY" + scripts/ci/desktop-linux/test-summary.py apps/multiplatform/common/build/test-results/desktopUiTest/pass1 + + # a test's directory, with its peers' transcripts, is kept only when the test failed + - name: Upload UI test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: ui-tests-desktop-${{ matrix.shard }} + path: | + apps/multiplatform/common/build/reports/tests/desktopUiTest + apps/multiplatform/common/build/test-results/desktopUiTest/*/*.xml + apps/multiplatform/build/uitest/run-*/failures + apps/multiplatform/build/uitest/run-*/jvm-*/t*/peers/*/transcript.log + apps/multiplatform/build/uitest/run-*/hs_err_pid*.log + retention-days: 7 + if-no-files-found: warn + + # an emulator runs one test at a time, so the tests are split over several. Each shard builds the APKs itself: it + # compiles the host-side bridge anyway, whose classpath names files in the job's own Gradle cache + android: + name: Android UI tests (${{ matrix.shard }}) + needs: [plan, android-core, bin] + # the builds are skipped when an earlier run built them + if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' && needs.plan.outputs.run-android == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 240 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.android-shards) }} + 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 CLI and test servers the rest + - name: Install libraries + run: | + sudo apt-get update + sudo apt-get install -y libpulse0 libegl1 libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev + dpkg-query -W libpulse0 libegl1 libffi-dev libgmp-dev libnuma-dev libssl-dev zlib1g-dev + + - name: Download core library + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.android-core }} + path: ~/core + run-id: ${{ needs.plan.outputs.android-core-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Install core library + run: scripts/ci/android/install-core.sh ~/core/${{ env.ANDROID_CORE_PKG }} + + - name: Download CLI and test servers + uses: actions/download-artifact@v8 + with: + name: ${{ needs.plan.outputs.bin }} + path: ~/bin + run-id: ${{ needs.plan.outputs.bin-run || github.run_id }} + github-token: ${{ github.token }} + + - name: Install CLI and test servers + run: scripts/ci/ui-tests/install-bin.sh ~/bin/${{ env.BIN_PKG }} "$RUNNER_TEMP/bin" + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: corretto + java-version: ${{ env.JAVA_VER }} + + # as android-emu builds its APK, plus the test APK and the host-side bridge's classpath, which uitest-android.sh + # then finds up to date; all before install-sdk.sh points ANDROID_HOME at an SDK with only the emulator + - name: Build app, tests and bridge + working-directory: apps/multiplatform + run: | + ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pandroid.injected.build.abi=arm64-v8a \ + :android:assembleFossDebug :android:assembleFossDebugAndroidTest :common:uitestBridgeClasspath + pkill -f KotlinCompileDaemon || true + mkdir -p "$RUNNER_TEMP/apk" + cp "android/build/intermediates/apk/foss/debug/$APK" "$RUNNER_TEMP/apk" + test_apk=$(find android/build -name 'android-foss-debug-androidTest.apk' | head -n 1) + [ -n "$test_apk" ] || { echo "error: no androidTest APK"; find android/build -name '*.apk'; exit 1; } + cp "$test_apk" "$RUNNER_TEMP/apk/androidTest.apk" + ls -la "$RUNNER_TEMP/apk" + unzip -l "$RUNNER_TEMP/apk/$APK" | grep -E ' lib/' + + - name: Prepare test peers and servers + working-directory: apps/multiplatform + run: scripts/uitest.sh --prepare-only + + - 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 apps/multiplatform/build/uitest-android/emulator + + # without local.properties' application_id.suffix the debug app's id is chat.simplex.app + - name: Run UI tests + id: run + working-directory: apps/multiplatform + env: + ARGS: ${{ matrix.args }} + run: | + read -r -a args <<< "$ARGS" + serial=$("$ANDROID_HOME/platform-tools/adb" devices | awk 'NR > 1 && $2 == "device" {print $1; exit}') + echo "device $serial" + free -m + export ADB="$ANDROID_HOME/platform-tools/adb" UITEST_SERIAL="$serial" UITEST_APP_ID=chat.simplex.app + scripts/uitest-android.sh "$RUNNER_TEMP/apk/$APK" "$RUNNER_TEMP/apk/androidTest.apk" "${args[@]}" + + - name: Report UI tests + if: ${{ !cancelled() && steps.run.outcome != 'skipped' }} + env: + SHARD: ${{ matrix.shard }} + run: | + echo "## Android UI tests, shard $SHARD" >> "$GITHUB_STEP_SUMMARY" + run=$(ls -1dt apps/multiplatform/build/uitest-android/run-* | head -n 1) + scripts/ci/ui-tests/android-summary.py "$run" + + # the bridge keeps its peers' transcripts for every test + - name: Upload UI test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: ui-tests-android-${{ matrix.shard }} + path: | + apps/multiplatform/build/uitest-android + apps/multiplatform/build/uitest/run-*/jvm-*/bridge-peers/*/transcript.log + retention-days: 7 + if-no-files-found: warn diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/AndroidHelpers.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/AndroidHelpers.kt new file mode 100644 index 0000000000..b0021230a5 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/AndroidHelpers.kt @@ -0,0 +1,125 @@ +package chat.simplex.app.ui.android + +import android.app.Notification +import android.app.NotificationManager +import android.content.ComponentName +import android.os.ParcelFileDescriptor +import android.os.PowerManager +import android.view.KeyEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.state.ToggleableState +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.test.espresso.Espresso +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.ViewInteraction +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import androidx.work.WorkInfo +import chat.simplex.app.SimplexService +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTestApp +import chat.simplex.common.model.NotificationsMode +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.getWorkManagerInstance +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import chat.simplex.common.views.usersettings.changeNotificationsMode +import org.hamcrest.CoreMatchers +import org.hamcrest.Matcher + +internal fun shell(cmd: String): String = + ParcelFileDescriptor.AutoCloseInputStream(InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(cmd)).bufferedReader().use { it.readText() } + +internal fun serviceNotification(): Notification? = + androidAppContext.getSystemService(NotificationManager::class.java).activeNotifications.firstOrNull { it.id == SimplexService.SIMPLEX_SERVICE_ID }?.notification + +internal fun receiverState(receiver: Class<*>): Int = + androidAppContext.packageManager.getComponentEnabledSetting(ComponentName(androidAppContext.packageName, receiver.name)) + +internal fun uniqueWorkActive(name: String): Boolean = + androidAppContext.getWorkManagerInstance().getWorkInfosForUniqueWork(name).get().any { !it.state.isFinished } + +internal fun workStates(name: String): List = + androidAppContext.getWorkManagerInstance().getWorkInfosForUniqueWork(name).get().map { it.state } + +internal fun UiScope.withBackgroundServiceAllowed(body: () -> Unit) { + val pkg = androidAppContext.packageName + val allowListed = androidAppContext.getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(pkg) + if (!allowListed) shell("dumpsys deviceidle whitelist +$pkg") + val result = runCatching { + poll(what = "the app allowed to run in background (battery allow-list, no background restriction)") { SimplexService.isBackgroundAllowed() } + body() + } + val stopped = runCatching { + UiTestApp.onMain { changeNotificationsMode(NotificationsMode.OFF, chatModel) } + poll(what = "the SimpleX Chat service to stop and its notification to go") { !SimplexService.isServiceStarted && !SimplexService.isServiceStarting && serviceNotification() == null } + } + if (!allowListed) shell("dumpsys deviceidle whitelist -$pkg") + result.getOrThrow() + stopped.getOrThrow() +} + +internal class WindowFrame(val height: Int, val keyboardTop: Int) + +internal fun windowFrame(): WindowFrame { + var frame: WindowFrame? = null + UiTestApp.onMain { + val decor = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).firstOrNull()?.window?.decorView + val insets = decor?.let { ViewCompat.getRootWindowInsets(it) } + if (decor != null && insets != null) frame = WindowFrame(decor.height, decor.height - insets.getInsets(WindowInsetsCompat.Type.ime()).bottom) + } + return frame ?: error("no resumed activity window to measure") +} + +internal fun UiScope.settledKeyboardTop(): Int { + var last = -1 + poll(what = "the soft keyboard to finish opening") { + val f = windowFrame() + (keyboardShown() && f.keyboardTop < f.height && f.keyboardTop == last).also { last = f.keyboardTop } + } + settle() + return windowFrame().keyboardTop +} + +internal fun UiScope.boundsInWindow(key: UiKey): Rect { + waitFor(key) + return rule.onNode(matcher(key)).fetchSemanticsNode().boundsInWindow +} + +internal fun UiScope.toggleState(key: UiKey): ToggleableState? { + waitFor(key) + return rule.onNode(matcher(key)).fetchSemanticsNode().config.getOrElseNullable(SemanticsProperties.ToggleableState) { null } +} + +internal fun messageEditText(): ViewInteraction = + Espresso.onView(CoreMatchers.allOf(ViewMatchers.isAssignableFrom(EditText::class.java), ViewMatchers.withTagValue(CoreMatchers.equalTo(Ui.Chat.Message.tag)))) + +internal fun messageLineCount(): Int { + var lines = -1 + messageEditText().check { v, e -> lines = (v as? EditText)?.lineCount ?: throw (e ?: AssertionError("the message field is not an EditText")) } + return lines +} + +internal fun pressImeEnter() { + messageEditText().perform(ImeEnterKey()) +} + +private class ImeEnterKey : ViewAction { + override fun getConstraints(): Matcher = ViewMatchers.isAssignableFrom(EditText::class.java) + override fun getDescription() = "send the Enter key through the input connection, as the soft keyboard does" + override fun perform(uiController: UiController, view: View) { + val ic = (view as EditText).onCreateInputConnection(EditorInfo()) ?: error("the message field has no input connection") + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)) + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)) + uiController.loopMainThreadUntilIdle() + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/AppLinksAndroidTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/AppLinksAndroidTest.kt new file mode 100644 index 0000000000..535339f642 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/AppLinksAndroidTest.kt @@ -0,0 +1,110 @@ +package chat.simplex.app.ui.android + +import android.content.Intent +import android.net.Uri +import chat.simplex.app.MainActivity +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.CliPeer +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.createOneTimeLink +import chat.simplex.app.ui.harness.planConfirm +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppLinksAndroidTest : UiTest() { + @Test fun appLink_oneTimeLink_opensPlan() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = bobsOneTimeLink(bob) + alice.chatList.row(NOTES) + check(chatModel.chats.value.none { it.chatInfo.chatViewName == "bob" } && !exists(planConfirm)) { "bob's chat or a plan alert exists before the link is opened" } + deliverToApp(viewIntent(link)) + awaitPlanForBob() + click(planConfirm) + poll(what = "bob's prepared chat open with Connect") { preparedBobOpen() && exists(Ui.Chat.Connect) } + click(Ui.Chat.Connect) + assertEquals("alice: contact is connected", bob.nextLine(CONNECT_TIMEOUT_MS)) + } + + @Test fun appLink_noData_showsInvalidLinkAlert() = uiTest { + app("alice").chatList.row(NOTES) + check(!AlertManager.shared.hasAlertsShown() && !seesText(INVALID_LINK)) { "an alert is shown before the link is opened" } + deliverToApp(viewIntent(null)) + poll(what = "the `$INVALID_LINK` alert") { seesText(INVALID_LINK) && seesText("Please check that SimpleX link is correct.") } + check(!exists(planConfirm) && !AlertManager.privacySensitive.hasAlertsShown()) { "a link without data opened a connection plan" } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert closed over the chat list") { !AlertManager.shared.hasAlertsShown() && exists(Ui.ChatList.Search) } + } + + @Test fun appLink_whileChatStopped_openedAfterStart() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = bobsOneTimeLink(bob) + stopChatFromChatData(alice) + deliverToApp(viewIntent(link)) + poll(what = "the link queued by the app") { chatModel.appOpenUrl.value?.second == link } + settle() + check(chatModel.appOpenUrl.value?.second == link && !exists(planConfirm) && !AlertManager.privacySensitive.hasAlertsShown()) { + "the link was handled while the chat was stopped: queued ${chatModel.appOpenUrl.value}, texts ${texts().take(20)}" + } + click(Ui.Database.RunChat) + awaitPlanForBob() + check(chatModel.chatRunning.value == true && chatModel.appOpenUrl.value == null) { "the plan opened with the chat running ${chatModel.chatRunning.value}, queued ${chatModel.appOpenUrl.value}" } + click(planConfirm) + poll(what = "bob's prepared chat open in place of Chat data") { preparedBobOpen() && !exists(Ui.Database.RunChat) } + } + + @Test fun appLink_intentFilter_matchesOnlyConnectionPaths() = uiTest { + val notOpening = OPENS.filterNot(::opensMainActivity) + val wronglyOpening = IGNORED.filter(::opensMainActivity) + check(notOpening.isEmpty() && wronglyOpening.isEmpty()) { + "links that must open the app but do not: $notOpening; links that open the app but must not: $wronglyOpening" + } + } + + private fun bobsOneTimeLink(bob: CliPeer): String = + bob.createOneTimeLink().also { bob.eventually("and ask them to connect: /c ") } + + private fun UiScope.awaitPlanForBob() = + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the plan alert for bob with `Open new chat`") { exists(planConfirm) && "Open new chat" in textsOf(planConfirm) && seesText("bob") } + + private fun preparedBobOpen(): Boolean = + chatModel.chats.value.firstOrNull { it.chatInfo.chatViewName == "bob" && it.chatInfo.nextConnectPrepared }?.id?.let { it == chatModel.chatId.value } == true + + private fun opensMainActivity(link: String): Boolean = + androidAppContext.packageManager.queryIntentActivities(Intent(Intent.ACTION_VIEW, Uri.parse(link)).setPackage(androidAppContext.packageName), 0) + .any { it.activityInfo.name == MainActivity::class.java.name } + + private companion object { + const val NOTES = "Private notes" + const val INVALID_LINK = "Invalid link" + val OPENS = listOf( + "simplex:/contact#/?v=2-7&smp=x", + "simplexchat:/open", + "https://simplex.chat/contact#/?v=2-7&smp=x", + "https://simplex.chat/invitation#/?v=2-7&smp=x", + "https://simplex.chat/a#key", + "https://simplex.chat/c#key", + "https://simplex.chat/g#key", + "https://simplex.chat/i#key", + "https://simplex.chat/contact/#/?v=2-7&smp=x", + "https://simplex.chat/i/#key", + "https://smp4.simplex.im/a#key", + "https://smp19.simplex.im/i#key", + "https://smp1.simplexonflux.com/c#key", + "https://smp6.simplexonflux.com/g#key", + ) + val IGNORED = listOf( + "https://simplex.chat/", + "https://simplex.chat/crowdfunding/", + "https://simplex.chat/blog/", + "https://simplex.chat/contacts", + "https://simplex.chat/about", + "https://localhost/a#key", + "https://example.com/i#key", + ) + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/AppearanceAndroidTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/AppearanceAndroidTest.kt new file mode 100644 index 0000000000..d1c6f5c8b3 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/AppearanceAndroidTest.kt @@ -0,0 +1,58 @@ +package chat.simplex.app.ui.android + +import androidx.compose.ui.state.ToggleableState +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import chat.simplex.common.views.helpers.ModalManager +import org.junit.Test + +class AppearanceAndroidTest : UiTest() { + @Test fun appearanceAndroid_interfaceSectionToolbars() = uiTest { + val alice = app("alice") + waitFor(Ui.ChatList.Search) + check(storedSetting(ONE_HAND) == null && storedSetting(CHAT_BOTTOM) == null) { "the toolbar prefs are not at their defaults: ${storedSetting(ONE_HAND)}, ${storedSetting(CHAT_BOTTOM)}" } + poll(what = "the chat list search field and toolbar at the bottom of the screen") { lowerHalf(Ui.ChatList.Search) == true && lowerHalf(Ui.ChatList.Bar) == true } + alice.chatList.open(NOTES) + poll(what = "the notes toolbar at the bottom of the screen") { lowerHalf(CHAT_TITLE) == true } + backToChatList() + + alice.appearance() + check(toggleState(Ui.Appearance.OneHandUI) == ToggleableState.On && toggleState(Ui.Appearance.ChatBottomBar) == ToggleableState.On) { + "Reachable app toolbars ${toggleState(Ui.Appearance.OneHandUI)}, Reachable chat toolbar ${toggleState(Ui.Appearance.ChatBottomBar)}" + } + click(Ui.Appearance.ChatBottomBar) + poll(what = "Reachable chat toolbar off") { storedSetting(CHAT_BOTTOM) == "false" && toggleState(Ui.Appearance.ChatBottomBar) == ToggleableState.Off } + click(Ui.Appearance.OneHandUI) + poll(what = "Reachable app toolbars off, hiding Reachable chat toolbar") { + storedSetting(ONE_HAND) == "false" && toggleState(Ui.Appearance.OneHandUI) == ToggleableState.Off && !exists(Ui.Appearance.ChatBottomBar) + } + closeAppearance() + poll(what = "the chat list search field and toolbar at the top of the screen") { lowerHalf(Ui.ChatList.Search) == false && lowerHalf(Ui.ChatList.Bar) == false } + + alice.appearance() + click(Ui.Appearance.OneHandUI) + poll(what = "Reachable app toolbars on, showing Reachable chat toolbar turned on again") { + storedSetting(ONE_HAND) == "true" && exists(Ui.Appearance.ChatBottomBar) && toggleState(Ui.Appearance.ChatBottomBar) == ToggleableState.On && storedSetting(CHAT_BOTTOM) == "true" + } + } + + private fun UiScope.lowerHalf(key: UiKey): Boolean? = + rule.onAllNodes(matcher(key)).fetchSemanticsNodes().firstOrNull()?.boundsInWindow?.let { it.top >= windowFrame().height / 2f } + + private fun UiScope.closeAppearance() { + escape() + poll(what = "Your settings again") { exists(Ui.Settings.Appearance) && !exists(Ui.Appearance.OneHandUI) } + escape() + poll(what = "the chat list with nothing open") { !ModalManager.start.hasModalsOpen() && !exists(Ui.Settings.Appearance) && !exists(Ui.UserPicker.Sheet) && exists(Ui.ChatList.Search) } + } + + private companion object { + const val ONE_HAND = "OneHandUI" + const val CHAT_BOTTOM = "ChatBottomBar" + const val NOTES = "Private notes" + val CHAT_TITLE = Ui.Chat.Bar.part("title") + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/ComposeKeyboardAndroidTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/ComposeKeyboardAndroidTest.kt new file mode 100644 index 0000000000..1d0159061e --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/ComposeKeyboardAndroidTest.kt @@ -0,0 +1,87 @@ +package chat.simplex.app.ui.android + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ComposeKeyboardAndroidTest : UiTest() { + @Test fun compose_enterKeyInsertsNewline_sendButtonSends() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + typeInto(Ui.Chat.Message, "hello") + pressImeEnter() + poll(what = "a line break after `hello` in the message field") { editableText(Ui.Chat.Message) == "hello\n" } + check(chat.itemTexts().none { "hello" in it }) { "Enter sent the message: ${chat.itemTexts()}" } + chat.send("there") + bob.expectsMessage("alice> hello", CONNECT_TIMEOUT_MS) + bob expects "there" + } + + @Test fun compose_replyAndEdit_showKeyboard() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send(MINE) + bob.expectsMessage("alice> $MINE", CONNECT_TIMEOUT_MS) + bob sends "@alice $QUESTION" + val question = chat.itemId(QUESTION) + hideKeyboard() + rightClick(Ui.Chat.Item(question)) + waitFor(Ui.Chat.ItemMenu(question, "reply")) + check(!keyboardShown() && !exists(CANCEL_CONTEXT)) { "the soft keyboard or a compose context is open before Reply" } + click(Ui.Chat.ItemMenu(question, "reply")) + poll(what = "the soft keyboard to open for the reply") { keyboardShown() && exists(CANCEL_CONTEXT) } + val keyboardTop = settledKeyboardTop() + val field = boundsInWindow(Ui.Chat.Message) + val quoted = boundsInWindow(Ui.Chat.Item(question)) + check(field.bottom <= keyboardTop && quoted.bottom <= keyboardTop) { + "with the keyboard open (top at $keyboardTop px) the message field is at $field and bob's message at $quoted" + } + + hideKeyboard() + chat.menu(MINE, "edit") + poll(what = "the soft keyboard to open for editing `$MINE`") { keyboardShown() && editableText(Ui.Chat.Message) == MINE } + } + + @Test fun compose_editText_clearButtonForWrappedParagraph() = uiTest { + val alice = app("alice") + alice.chatList.open(NOTES) + typeInto(Ui.Chat.Message, "short") + poll(what = "`short` on one line of the message field") { messageLineCount() == 1 } + settle() + check(!exists(Ui.Chat.ClearText)) { "the clear-text button is shown for one line" } + replaceText(Ui.Chat.Message, PARAGRAPH) + poll(what = "the paragraph wrapped to four lines or more, with the clear-text button") { messageLineCount() >= 4 && exists(Ui.Chat.ClearText) } + check(editableText(Ui.Chat.Message)?.contains('\n') == false) { "the paragraph has a line break" } + hideKeyboard() + click(Ui.Chat.ClearText) + poll(what = "the message field cleared and the button gone") { editableText(Ui.Chat.Message).isNullOrEmpty() && !exists(Ui.Chat.ClearText) } + } + + @Test fun attachSheet_keyboardOpen_fullyExpanded() = uiTest { + val alice = app("alice") + alice.chatList.open(NOTES) + click(Ui.Chat.Message) + poll(what = "the soft keyboard to open for the message field") { keyboardShown() } + check(!seesDescription("Close sheet")) { "the attachment sheet is open before Attach" } + click(described("Attach")) + poll(what = "the attachment sheet with Camera, Image, Video and File") { seesDescription("Close sheet") && SHEET_BUTTONS.all { exists(it) } } + SHEET_BUTTONS.forEach { waitStable(it) } + val keyboardTop = settledKeyboardTop() + val bottoms = SHEET_BUTTONS.associateWith { boundsInWindow(it).bottom } + check(bottoms.values.all { it <= keyboardTop }) { "attachment buttons end at $bottoms, below the keyboard top at $keyboardTop px" } + } + + private companion object { + const val NOTES = "Private notes" + const val MINE = "my message to edit" + const val QUESTION = "question to reply to" + val PARAGRAPH = "word ".repeat(60).trim() + val SHEET_BUTTONS = listOf("camera", "image", "video", "file").map { Ui.Chat.Attach(it) } + val CANCEL_CONTEXT = described("Cancel") + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/KeystoreAndroidTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/KeystoreAndroidTest.kt new file mode 100644 index 0000000000..34fcaa030c --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/KeystoreAndroidTest.kt @@ -0,0 +1,142 @@ +package chat.simplex.app.ui.android + +import androidx.compose.ui.state.ToggleableState +import chat.simplex.app.ui.harness.DB_WORK_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.chatData +import chat.simplex.app.ui.harness.onboarding +import chat.simplex.app.ui.harness.typeSecret +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.chatCloseStore +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.initChatController +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.database.deleteChatDatabaseFilesAndState +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.DBMigrationResult +import chat.simplex.common.views.helpers.DatabaseUtils +import chat.simplex.common.views.onboarding.OnboardingStage +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.security.KeyStore + +class KeystoreAndroidTest : UiTest() { + @Test fun keystore_freshDb_randomPassphraseFooter() = uiTest { + val alice = app(null) + freshInstallDatabaseViaApi() + val randomKey = DatabaseUtils.ksDatabasePassword.get() + check(!randomKey.isNullOrEmpty() && chatModel.chatDbEncrypted.value == true && storedSetting("InitialRandomDBPassphrase") == "true") { + "the fresh database: key in the Keystore ${!randomKey.isNullOrEmpty()}, encrypted ${chatModel.chatDbEncrypted.value}, random ${storedSetting("InitialRandomDBPassphrase")}" + } + val o = onboarding() + o.createProfile("alice") + check(o.stage == OnboardingStage.Step3_ChooseServerOperators) { "the step after Create profile is ${o.stage}, not Your network" } + click(Ui.Onboarding.Continue) + poll(what = "Network commitments") { o.stage == OnboardingStage.Step4_NetworkCommitments && exists(Ui.Onboarding.Accept) } + click(Ui.Onboarding.Accept) + o.waitForChatList() + check(DatabaseUtils.ksDatabasePassword.get() == randomKey && storedSetting("StoreDBPassphrase") == "true") { "onboarding changed the random key or stopped storing it" } + + chatData(alice).management().passphrase() + val stored = storedFooter() + poll(what = "the random-passphrase footers") { seesText(stored) && seesText(RANDOM) } + check(toggleState(Ui.DbPassphrase.Save) == ToggleableState.On && !isEnabled(Ui.DbPassphrase.Save)) { + "Save passphrase in Keystore: ${toggleState(Ui.DbPassphrase.Save)}, enabled ${isEnabled(Ui.DbPassphrase.Save)}" + } + check(!exists(Ui.DbPassphrase.Current) && !seesText(NO_RECOVERY, substring = true)) { "the random passphrase screen asks for the current passphrase or shows the no-recovery note" } + + typeSecret(Ui.DbPassphrase.New, NEW) + typeSecret(Ui.DbPassphrase.Confirm, NEW) + hideKeyboard() + click(Ui.DbPassphrase.Update) + poll(what = "the Change database passphrase? alert naming the Keystore") { + seesText("Change database passphrase?") && seesText(WILL_STORE, substring = true) && seesText(STORE_SECURELY, substring = true) && + "Update" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the Database encrypted! alert") { seesText("Database encrypted!") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the chat running again and the Current passphrase field") { + !AlertManager.shared.hasAlertsShown() && chatModel.chatRunning.value == true && exists(Ui.DbPassphrase.Current) + } + poll(what = "the stored-passphrase footers without the random one") { seesText(stored) && seesText(NO_RECOVERY, substring = true) && !seesText(RANDOM) } + check(toggleState(Ui.DbPassphrase.Save) == ToggleableState.On && isEnabled(Ui.DbPassphrase.Save)) { + "Save passphrase in Keystore after the change: ${toggleState(Ui.DbPassphrase.Save)}, enabled ${isEnabled(Ui.DbPassphrase.Save)}" + } + check(DatabaseUtils.ksDatabasePassword.get() == NEW && storedSetting("InitialRandomDBPassphrase") == "false") { + "after the change: Keystore holds the new passphrase ${DatabaseUtils.ksDatabasePassword.get() == NEW}, random ${storedSetting("InitialRandomDBPassphrase")}" + } + } + + @Test fun keystore_keyMissing_showsNotFoundAlert() = uiTest { + val alice = app("alice") + val notes = alice.chatList.open(NOTES) + notes.send(NOTE) + notes.itemId(NOTE) + backToChatList() + encryptDatabaseViaApi(NEW) + check(DatabaseUtils.ksDatabasePassword.get() == NEW && storedSetting("StoreDBPassphrase") == "true") { "the passphrase is not stored in the Keystore before the key is lost" } + deleteKeystoreKeyViaApi() + check(storedSetting("EncryptedDBPassphrase") != null && !seesText(NOT_FOUND)) { "the encrypted passphrase is gone from the prefs, or the alert shows before the restart" } + val after = restart(ready = { chatModel.chatDbStatus.value is DBMigrationResult.ErrorNotADatabase && !chatModel.ctrlInitInProgress.value }, what = "the database left locked without its key") + poll(what = "the Wrong database passphrase alert saying the passphrase is not in the Keystore") { + AlertManager.shared.hasAlertsShown() && seesText("Wrong database passphrase") && seesText(NOT_FOUND) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the Encrypted database prompt with Save passphrase and open chat") { + !AlertManager.shared.hasAlertsShown() && seesText("Encrypted database") && "Save passphrase and open chat" in textsOf(Ui.DbError.Open) + } + typeSecret(Ui.DbError.Passphrase, NEW) + pressEnter(Ui.DbError.Passphrase) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the chat list with alice's note after the keyboard's Done key") { + chatModel.chatDbStatus.value == DBMigrationResult.OK && chatModel.chatRunning.value == true && NOTE in after.chatList.rowTexts(NOTES) + } + check(DatabaseUtils.ksDatabasePassword.get() == NEW) { "Save passphrase and open chat did not store the passphrase in the Keystore again" } + } + + private fun freshInstallDatabaseViaApi() { + val ctrl = chatController.getChatCtrl() + if (ctrl != null && ctrl != -1L) chatCloseStore(ctrl).let { check(it.isEmpty()) { "chatCloseStore failed: $it" } } + chatController.setChatCtrl(null) + deleteChatDatabaseFilesAndState() + appPrefs.initialRandomDBPassphrase.set(false) + runBlocking { initChatController() } + check(chatModel.chatDbStatus.value == DBMigrationResult.OK) { "fresh database: ${chatModel.chatDbStatus.value}" } + } + + private fun encryptDatabaseViaApi(passphrase: String) { + val current = DatabaseUtils.ksDatabasePassword.get() ?: "" + val error = runBlocking { + chatController.apiStopChat() + chatModel.chatRunning.value = false + chatController.apiStorageEncryption(currentKey = current, newKey = passphrase) + } + check(error == null) { "encrypting the database failed: $error" } + DatabaseUtils.ksDatabasePassword.set(passphrase) + appPrefs.initialRandomDBPassphrase.set(false) + chatModel.chatDbEncrypted.value = true + } + + private fun deleteKeystoreKeyViaApi() { + val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + ks.deleteEntry(DB_KEY_ALIAS) + check(!ks.containsAlias(DB_KEY_ALIAS)) { "the Keystore still holds $DB_KEY_ALIAS" } + } + + private fun storedFooter(): String = + "Android Keystore${DatabaseUtils.ksDatabasePassword.storage()?.let { " ($it)" } ?: ""} is used to securely store passphrase - it allows notification service to work." + + private companion object { + const val NEW = "uitest-keystore-5Hq" + const val NOTES = "Private notes" + const val NOTE = "written before the key was lost" + const val DB_KEY_ALIAS = "databasePassword" + const val RANDOM = "Database is encrypted using a random passphrase, you can change it." + const val NO_RECOVERY = "you will NOT be able to recover or change passphrase if you lose it." + const val WILL_STORE = "Database encryption passphrase will be updated and stored in the Keystore." + const val STORE_SECURELY = "Please store passphrase securely, you will NOT be able to change it if you lose it." + const val NOT_FOUND = "Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app's data using a backup tool. If it's not the case, please contact developers." + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/NotificationModesAndroidTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/NotificationModesAndroidTest.kt new file mode 100644 index 0000000000..36b5677a4e --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/NotificationModesAndroidTest.kt @@ -0,0 +1,124 @@ +package chat.simplex.app.ui.android + +import android.Manifest +import android.app.Notification +import android.content.pm.PackageManager +import chat.simplex.app.SimplexService +import chat.simplex.app.ui.harness.App +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.described +import chat.simplex.common.model.NotificationsMode +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import org.junit.Test + +class NotificationModesAndroidTest : UiTest() { + @Test fun ntfMode_switchToService_startsForegroundService() = uiTest { + val alice = app("alice") + requireNotificationPermission() + withBackgroundServiceAllowed { + openNotificationSettings(alice) + check(OFF_TITLE in textsOf(Ui.Notifications.Mode)) { "Notification service row: ${textsOf(Ui.Notifications.Mode)}" } + openModePicker() + check(MODES.all { (mode, title) -> title in textsOf(option(mode)) }) { "mode options: ${MODES.map { textsOf(option(it.first)) }}" } + check(checkedTitles() == listOf(OFF_TITLE) && seesText(OFF_DESC)) { "picker before the change: checked ${checkedTitles()}" } + check(serviceNotification() == null && receiverState(START_RECEIVER) != PackageManager.COMPONENT_ENABLED_STATE_ENABLED && !uniqueWorkActive(RESTART_WORK)) { + "before Always on: service notification ${serviceNotification() != null}, StartReceiver ${receiverState(START_RECEIVER)}, restart work ${workStates(RESTART_WORK)}" + } + + click(option(NotificationsMode.SERVICE)) + poll(what = "Always on checked and stored, with its description") { + storedSetting(MODE_KEY) == NotificationsMode.SERVICE.name && checkedTitles() == listOf(SERVICE_TITLE) && seesText(SERVICE_DESC) + } + poll(what = "the SimpleX Chat service notification with Hide") { + serviceNotification()?.let { n -> + n.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() == "SimpleX Chat service" && + n.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() == "Receiving messages…" && + n.channelId == SimplexService.NOTIFICATION_CHANNEL_ID && n.actions.orEmpty().any { it.title?.toString() == "Hide" } + } == true && SimplexService.isServiceStarted + } + poll(what = "the boot and update receivers enabled and the restart worker scheduled") { + receiverState(START_RECEIVER) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED && + receiverState(UPDATE_RECEIVER) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED && uniqueWorkActive(RESTART_WORK) + } + check(!AlertManager.shared.hasAlertsShown()) { "switching to Always on while allowed in background showed an alert: ${texts().take(20)}" } + escape() + poll(what = "Notifications showing Always on") { exists(Ui.Notifications.Mode) && SERVICE_TITLE in textsOf(Ui.Notifications.Mode) } + + openModePicker() + click(option(NotificationsMode.OFF)) + poll(what = "Runs when app is open checked and stored") { storedSetting(MODE_KEY) == NotificationsMode.OFF.name && checkedTitles() == listOf(OFF_TITLE) } + poll(what = "the service stopped, its notification gone, the receivers disabled and the restart worker cancelled") { + serviceNotification() == null && !SimplexService.isServiceStarted && + receiverState(START_RECEIVER) == PackageManager.COMPONENT_ENABLED_STATE_DISABLED && + receiverState(UPDATE_RECEIVER) == PackageManager.COMPONENT_ENABLED_STATE_DISABLED && !uniqueWorkActive(RESTART_WORK) + } + check(!AlertManager.shared.hasAlertsShown()) { "switching to Runs when app is open showed an alert: ${texts().take(20)}" } + } + } + + @Test fun ntfMode_switchToPeriodic_schedulesFetcher() = uiTest { + val alice = app("alice") + requireNotificationPermission() + withBackgroundServiceAllowed { + val fetcher = "${androidAppContext.packageName}.UNIQUE_MESSAGES_FETCHER" + openNotificationSettings(alice) + openModePicker() + check(checkedTitles() == listOf(OFF_TITLE) && !uniqueWorkActive(fetcher)) { "before Starts periodically: checked ${checkedTitles()}, fetcher ${workStates(fetcher)}" } + + click(option(NotificationsMode.PERIODIC)) + poll(what = "Starts periodically checked and stored, with its description") { + storedSetting(MODE_KEY) == NotificationsMode.PERIODIC.name && checkedTitles() == listOf(PERIODIC_TITLE) && seesText(PERIODIC_DESC) + } + poll(what = "the periodic message fetcher scheduled") { uniqueWorkActive(fetcher) } + check(serviceNotification() == null && !SimplexService.isServiceStarted && receiverState(START_RECEIVER) == PackageManager.COMPONENT_ENABLED_STATE_DISABLED) { + "Starts periodically: service notification ${serviceNotification() != null}, service started ${SimplexService.isServiceStarted}, StartReceiver ${receiverState(START_RECEIVER)}" + } + check(!uniqueWorkActive(RESTART_WORK) && !AlertManager.shared.hasAlertsShown()) { "Starts periodically: restart work ${workStates(RESTART_WORK)}, alert ${AlertManager.shared.hasAlertsShown()}" } + escape() + poll(what = "Notifications showing Starts periodically") { exists(Ui.Notifications.Mode) && PERIODIC_TITLE in textsOf(Ui.Notifications.Mode) } + + openModePicker() + click(option(NotificationsMode.OFF)) + poll(what = "the periodic message fetcher cancelled with Runs when app is open stored") { + storedSetting(MODE_KEY) == NotificationsMode.OFF.name && !uniqueWorkActive(fetcher) + } + } + } + + private fun requireNotificationPermission() = + check(androidAppContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { + "the runner must grant POST_NOTIFICATIONS (setup item 2): without it the service does not start" + } + + private fun UiScope.openNotificationSettings(app: App) { + app.settings() + click(described("Notifications")) + waitFor(Ui.Notifications.Mode) + } + + private fun UiScope.openModePicker() { + click(Ui.Notifications.Mode) + waitFor(option(NotificationsMode.SERVICE)) + } + + private fun UiScope.checkedTitles(): List = MODES.map { it.second }.filter { seesDescription(it) } + + private fun option(mode: NotificationsMode) = Ui.Notifications.Mode.option(mode) + + private companion object { + const val MODE_KEY = "NotificationsMode" + const val OFF_TITLE = "Runs when app is open" + const val PERIODIC_TITLE = "Starts periodically" + const val SERVICE_TITLE = "Always on" + const val OFF_DESC = "App can receive notifications only when it's running, no background service will be started" + const val PERIODIC_DESC = "Checks new messages every 10 minutes for up to 1 minute" + const val SERVICE_DESC = "Background service is always running – notifications will be shown as soon as the messages are available." + const val RESTART_WORK = SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC + val MODES = listOf(NotificationsMode.OFF to OFF_TITLE, NotificationsMode.PERIODIC to PERIODIC_TITLE, NotificationsMode.SERVICE to SERVICE_TITLE) + val START_RECEIVER = SimplexService.StartReceiver::class.java + val UPDATE_RECEIVER = SimplexService.AppUpdateReceiver::class.java + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/OnboardingProfileAndroidTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/OnboardingProfileAndroidTest.kt new file mode 100644 index 0000000000..93ba92ccd3 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/OnboardingProfileAndroidTest.kt @@ -0,0 +1,38 @@ +package chat.simplex.app.ui.android + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.onboarding +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.migration.MigrationToState +import chat.simplex.common.views.onboarding.OnboardingStage +import org.junit.Test + +class OnboardingProfileAndroidTest : UiTest() { + @Test fun androidOnboardingCreateProfileMobileLayout() = uiTest { + app(null) + waitFor(Ui.Onboarding.GetStarted) + check(!keyboardShown()) { "the soft keyboard is open on the welcome screen" } + click(Ui.Onboarding.GetStarted) + poll(what = "Your profile with the name field focused and the soft keyboard open") { + onboarding().stage == OnboardingStage.Step2_CreateProfile && exists(Ui.Onboarding.ProfileName) && keyboardShown() + } + check(seesText("Your profile") && seesText("On your phone, not on servers.") && seesText(NO_ACCOUNT, substring = true) && seesText("Enter profile name…")) { + "the mobile profile screen: ${texts().take(30)}" + } + check(textsOf(Ui.Onboarding.Migrate).toSet() == setOf("Migrate")) { "migrate button: ${textsOf(Ui.Onboarding.Migrate)}" } + check("Create profile" in textsOf(Ui.Onboarding.CreateProfile) && !isEnabled(Ui.Onboarding.CreateProfile)) { "Create profile is enabled with an empty name" } + typeInto(Ui.Onboarding.ProfileName, "alice") + poll(what = "Create profile enabled for `alice`") { isEnabled(Ui.Onboarding.CreateProfile) } + + click(Ui.Onboarding.Migrate) + poll(what = "Migrate here leading with the camera section, then the archive link and file") { + chatModel.migrationState.value is MigrationToState.PasteOrScanLink && seesText("Scan QR code") && seesText("Or paste archive link") && seesText("Or import archive file") + } + check(!seesText("Paste archive link") && chatModel.currentUser.value == null) { "the desktop paste label is shown, or a profile was created: ${texts().take(30)}" } + } + + private companion object { + const val NO_ACCOUNT = "No account. No phone. No email. No ID." + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/SettingsAndroidTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/SettingsAndroidTest.kt new file mode 100644 index 0000000000..693f44599e --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/SettingsAndroidTest.kt @@ -0,0 +1,92 @@ +package chat.simplex.app.ui.android + +import android.content.Intent +import androidx.test.espresso.intent.Intents +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.chatRunningInCore +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.onboarding +import chat.simplex.app.ui.harness.openProfileMenu +import chat.simplex.app.ui.harness.userIdOf +import chat.simplex.app.BuildConfig +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.onboarding.OnboardingStage +import org.junit.Test + +class SettingsAndroidTest : UiTest() { + @Test fun settingsAndroid_rootListsAndroidItems() = uiTest { + val alice = app("alice") + alice.settings() + waitFor(Ui.Settings.Version) + val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" + poll(what = "Your settings with the Android rows and the Android version") { ROOT_ITEMS.all { seesText(it) } && version in textsOf(Ui.Settings.Version) } + val texts = texts() + val advanced = ADVANCED_ORDER.map { texts.indexOf(it) } + check(advanced.zipWithNext().all { (a, b) -> a in 0 until b } && texts.indexOf(version) > advanced.last()) { + "Advanced settings order ${ADVANCED_ORDER.zip(advanced)}, version at ${texts.indexOf(version)}" + } + + click(described("Shutdown")) + poll(what = "the Shutdown? alert with Cancel, Restart and Shutdown") { + AlertManager.shared.hasAlertsShown() && seesText("Shutdown?") && seesText(SHUTDOWN_DESC) && seesText("Restart") && + "Cancel" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the alert closed over Your settings") { !AlertManager.shared.hasAlertsShown() && exists(Ui.Settings.Version) } + check(chatModel.chatRunning.value == true && chatRunningInCore()) { "Cancel on the Shutdown? alert stopped the chat" } + + click(Ui.Settings.Version) + poll(what = "App version with the Android version, build and core version") { + seesText("App version: v${BuildConfig.VERSION_NAME}") && seesText("App build: ${BuildConfig.VERSION_CODE}") && + seesText("Core version: v", substring = true) && seesText("Developer tools") + } + } + + @Test fun helpAndroid_rateTheAppOpensMarket() = uiTest { + val alice = app("alice") + alice.settings() + click(Ui.Settings.Help) + waitFor(RATE) + check(seesText("Rate the app") && seesText("Contribute") && seesText("Star on GitHub")) { "Support the project rows: ${texts().take(40)}" } + check(viewIntents().isEmpty()) { "an ACTION_VIEW intent was fired before Rate the app: ${viewIntents()}" } + click(RATE) + poll(what = "an ACTION_VIEW intent for SimpleX Chat in the app store") { viewIntents().isNotEmpty() } + check(viewIntents() == listOf(MARKET_URI)) { "Rate the app opened ${viewIntents()}" } + } + + @Test fun profilesAndroid_deleteLastVisibleReturnsToOnboarding() = uiTest { + val alice = app("alice") + val aliceId = userIdOf("alice") + alice.userPicker().profiles() + waitFor(Ui.Profiles.User(aliceId)) + check(chatModel.users.size == 1 && chatRunningInCore()) { "users before the deletion: ${chatModel.users.map { it.user.displayName }}" } + openProfileMenu(aliceId, "delete") + click(Ui.Profiles.UserMenu(aliceId, "delete")) + val withConnections = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteWithConnections") + poll(what = "the `Delete chat profile?` alert with `Profile and server connections`") { seesText("Delete chat profile?") && exists(withConnections) } + click(withConnections) + poll(what = "the welcome screen with Get started in place of the profiles list") { + exists(Ui.Onboarding.GetStarted) && onboarding().stage == OnboardingStage.Step1_SimpleXInfo && !ModalManager.start.hasModalsOpen() && !exists(Ui.Profiles.Add) + } + check(chatModel.currentUser.value == null && chatModel.users.isEmpty() && !chatRunningInCore()) { + "after deleting the last profile: current ${chatModel.currentUser.value?.displayName}, users ${chatModel.users.size}, chat running ${chatRunningInCore()}" + } + } + + private fun viewIntents(): List = + Intents.getIntents().filter { it.action == Intent.ACTION_VIEW }.map { it.data.toString() } + + private companion object { + val RATE = described("Google Play") + const val MARKET_URI = "market://details?id=chat.simplex.app" + const val SHUTDOWN_DESC = "Notifications will stop working until you re-launch the app" + val ROOT_ITEMS = listOf( + "Your settings", "Appearance", "Your privacy", "Help & support", "Chat data", "Migrate to another device", "Supporter perks", + "Network & servers", "Notifications", "Audio & video calls", "Shutdown", "Crowdfunding on Wefunder" + ) + val ADVANCED_ORDER = listOf("Network & servers", "Notifications", "Audio & video calls", "Shutdown") + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/ShareIntoAppAndroidTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/ShareIntoAppAndroidTest.kt new file mode 100644 index 0000000000..63d98ce219 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/ShareIntoAppAndroidTest.kt @@ -0,0 +1,226 @@ +package chat.simplex.app.ui.android + +import android.net.Uri +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasTestTag +import chat.simplex.app.ui.back.backFromRoot +import chat.simplex.app.ui.back.relaunch +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.jpegFile +import chat.simplex.app.ui.harness.receivesCompressedImage +import chat.simplex.app.ui.harness.receivesFile +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.GroupFeatureEnabled +import chat.simplex.common.model.GroupPreferences +import chat.simplex.common.model.GroupProfile +import chat.simplex.common.model.RoleGroupPreference +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.ModalManager +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.io.File + +class ShareIntoAppAndroidTest : UiTest() { + @Test fun shareIn_text_toContact() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val bobId = alice.chatList.chatId("bob") + val chat = alice.chatList.open("bob") + chat.info() + waitFor(Ui.Info.Header) + check(ModalManager.end.hasModalsOpen() && shareListTitle() == null) { "bob's contact info is not open over his chat before the share" } + deliverToApp(shareTextIntent(TEXT)) + poll(what = "the share list titled `$SHARE_MESSAGE` in place of bob's chat and contact info") { + shareListTitle() == SHARE_MESSAGE && chatModel.chatId.value == null && !ModalManager.end.hasModalsOpen() && !exists(Ui.Info.Header) && exists(Ui.Share.Row(bobId)) + } + alice.chatList.shareTo("bob") + poll(what = "bob's chat with the shared text in the message field") { chatModel.chatId.value == bobId && editableText(Ui.Chat.Message) == TEXT } + chat.sendComposed() + bob.expectsMessage("alice> $TEXT", CONNECT_TIMEOUT_MS) + } + + @Test fun shareIn_file_toContact() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val bobId = alice.chatList.chatId("bob") + val pdf = File(exchangeDir(), PDF_NAME).apply { writeText(PDF_CONTENT) } + deliverToApp(shareStreamIntent("application/pdf", contentUri(pdf), CAPTION)) + awaitShareList(SHARE_FILE) + alice.chatList.shareTo("bob") + poll(what = "bob's chat with the preview of $PDF_NAME and the shared caption") { + chatModel.chatId.value == bobId && seesDescription("Cancel file preview") && seesText(PDF_NAME) && editableText(Ui.Chat.Message) == CAPTION + } + ChatScreen(this).sendComposed() + poll(what = "the file preview cleared after Send") { !seesDescription("Cancel file preview") } + bob.expectsMessage("alice> $CAPTION", CONNECT_TIMEOUT_MS) + bob expectsMessage "alice> sends file $PDF_NAME (${PDF_CONTENT.length} bytes / ${PDF_CONTENT.length} bytes)" + bob expects "use /fr 1 [/ | ] to receive it" + bob.receivesFile(1, PDF_NAME, "alice") + } + + @Test fun shareIn_image_toContact() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val bobId = alice.chatList.chatId("bob") + val (one, two, three) = listOf("share-1.jpg", "share-2.jpg", "share-3.jpg").map { contentUri(jpegFile(it)) } + deliverToApp(shareStreamIntent("image/jpeg", one)) + awaitShareList(SHARE_MEDIA) + alice.chatList.shareTo("bob") + poll(what = "bob's chat with one image preview") { chatModel.chatId.value == bobId && seesDescription("Cancel image preview") && previewImages() == 1 } + sendImages() + bob.receivesCompressedImage("alice> ", 1) + + deliverToApp(shareStreamsIntent("image/*", listOf(two, three))) + awaitShareList(SHARE_MEDIA) + alice.chatList.shareTo("bob") + poll(what = "bob's chat with two image previews") { chatModel.chatId.value == bobId && seesDescription("Cancel image preview") && previewImages() == 2 } + sendImages() + bob.receivesCompressedImage("alice> ", 2) + bob.receivesCompressedImage("alice> ", 3) + } + + @Test fun shareIn_fileSchemeUri_invalidPathAlert() = uiTest { + app("alice").chatList.row(NOTES) + val fileUri = Uri.parse("file:///sdcard/Download/shared.jpg") + val intents = listOf( + shareStreamIntent("image/jpeg", fileUri), + shareStreamsIntent("image/*", listOf(contentUri(jpegFile("share-ok.jpg")), fileUri)), + shareStreamIntent("text/plain", fileUri, TEXT), + shareStreamIntent("application/pdf", fileUri), + ) + for (intent in intents) { + val what = "${intent.action} ${intent.type}" + check(!AlertManager.shared.hasAlertsShown() && shareListTitle() == null) { "an alert or the share list is shown before $what" } + deliverToApp(intent) + poll(what = "the Invalid file path alert for $what") { seesText("Invalid file path") && seesText(INVALID_PATH) } + check(shareListTitle() == null && chatModel.sharedContent.value == null) { "$what with a file: URI was shared: ${chatModel.sharedContent.value}" } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert closed over the chat list after $what") { !AlertManager.shared.hasAlertsShown() && exists(Ui.ChatList.Search) } + } + } + + @Test fun shareIn_sendMultipleNonMedia_onlyClosesChat() = uiTest { + val alice = app("alice") + val notesId = alice.chatList.chatId(NOTES) + alice.chatList.open(NOTES) + waitFor(Ui.Chat.Message) + check(chatModel.chatId.value == notesId) { "Private notes did not open" } + val pdf = contentUri(File(exchangeDir(), PDF_NAME).apply { writeText(PDF_CONTENT) }) + deliverToApp(shareStreamsIntent("application/pdf", listOf(pdf, pdf))) + poll(what = "Private notes closed") { chatModel.chatId.value == null } + settle() + check(shareListTitle() == null && exists(Ui.ChatList.Search) && chatModel.sharedContent.value == null && !AlertManager.shared.hasAlertsShown()) { + "two shared PDFs: share list ${shareListTitle()}, shared content ${chatModel.sharedContent.value}, alert ${AlertManager.shared.hasAlertsShown()}" + } + } + + @Test fun shareIn_fileToGroupWithFilesOff_alert() = uiTest { + app("alice").chatList.row(NOTES) + newGroupViaApi(NO_FILES, GroupPreferences(files = RoleGroupPreference(GroupFeatureEnabled.OFF))) + newGroupViaApi(NO_LINKS, GroupPreferences(simplexLinks = RoleGroupPreference(GroupFeatureEnabled.OFF))) + val alice = restart() + poll(what = "both groups loaded after the restart") { chatModel.chats.value.count { it.chatInfo is ChatInfo.Group } == 2 } + val noFilesId = alice.chatList.chatId(NO_FILES) + val noLinksId = alice.chatList.chatId(NO_LINKS) + val linkText = "join me: ${invitationLinkViaApi()}" + val pdf = File(exchangeDir(), PDF_NAME).apply { writeText(PDF_CONTENT) } + + deliverToApp(shareStreamIntent("application/pdf", contentUri(pdf))) + awaitShareList(SHARE_FILE) + alice.chatList.shareTo(NO_FILES) + awaitProhibitedAlert() + alice.chatList.shareTo(NO_LINKS) + poll(what = "$NO_LINKS open with the preview of $PDF_NAME") { chatModel.chatId.value == noLinksId && seesDescription("Cancel file preview") && seesText(PDF_NAME) } + + deliverToApp(shareTextIntent(linkText)) + awaitShareList(SHARE_MESSAGE) + alice.chatList.shareTo(NO_LINKS) + awaitProhibitedAlert() + alice.chatList.shareTo(NO_FILES) + poll(what = "$NO_FILES open with the link in the message field") { chatModel.chatId.value == noFilesId && editableText(Ui.Chat.Message) == linkText } + } + + @Test fun shareIn_systemBack_dropsContentAndFinishes() = uiTest { + app("alice").chatList.row(NOTES) + deliverToApp(shareTextIntent(TEXT)) + awaitShareList(SHARE_MESSAGE) + backFromRoot() + check(chatModel.sharedContent.value == null) { "Back left the shared content: ${chatModel.sharedContent.value}" } + relaunch { + poll(what = "the chat list, not the share list, after relaunching") { exists(Ui.ChatList.Search) && shareListTitle() == null } + } + } + + @Test fun shareIn_chatStopped_showsStoppedIcon() = uiTest { + val alice = app("alice") + val notesId = alice.chatList.chatId(NOTES) + deliverToApp(shareTextIntent(TEXT)) + awaitShareList(SHARE_MESSAGE) + check(!seesDescription(STOPPED) && rowTappable(notesId)) { "the share list of a running chat: stopped icon ${seesDescription(STOPPED)}, notes tappable ${rowTappable(notesId)}" } + click(described("Back")) + poll(what = "the chat list after the toolbar Back, with the shared text dropped") { + shareListTitle() == null && exists(Ui.ChatList.Search) && chatModel.sharedContent.value == null + } + + stopChatFromChatData(alice) + deliverToApp(shareTextIntent(TEXT)) + awaitShareList(SHARE_MESSAGE) + check(seesDescription(STOPPED) && !rowTappable(notesId)) { "the share list of a stopped chat: stopped icon ${seesDescription(STOPPED)}, notes tappable ${rowTappable(notesId)}" } + click(described(STOPPED)) + poll(what = "the `$STOPPED` alert with how to start the chat") { AlertManager.shared.hasAlertsShown() && seesText(STOPPED) && seesText(START_HINT) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert closed over the share list") { !AlertManager.shared.hasAlertsShown() && shareListTitle() == SHARE_MESSAGE } + } + + private fun UiScope.previewImages(): Int = + rule.onAllNodes(hasContentDescription("preview image"), useUnmergedTree = true).fetchSemanticsNodes().size + + private fun UiScope.sendImages() { + click(Ui.Chat.Send) + poll(what = "the image previews cleared after Send") { !seesDescription("Cancel image preview") } + } + + private fun UiScope.awaitProhibitedAlert() { + poll(what = "the `Cannot send message` alert") { seesText("Cannot send message") && seesText("Selected chat preferences prohibit this message.") } + check(chatModel.chatId.value == null) { "a prohibited chat opened: ${chatModel.chatId.value}" } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert closed over the share list") { !AlertManager.shared.hasAlertsShown() && shareListTitle() != null } + } + + private fun UiScope.rowTappable(chatId: String): Boolean { + waitFor(Ui.Share.Row(chatId)) + return rule.onAllNodes(hasTestTag(Ui.Share.Row(chatId).tag) and hasClickAction()).fetchSemanticsNodes().isNotEmpty() + } + + private fun newGroupViaApi(name: String, prefs: GroupPreferences) { + runBlocking { chatController.apiNewGroup(null, incognito = false, GroupProfile(name, "", null, groupPreferences = prefs)) } ?: error("apiNewGroup($name) failed") + } + + private fun invitationLinkViaApi(): String = + runBlocking { chatController.apiAddContact(null, incognito = false) }.first?.first?.simplexChatUri(short = false) ?: error("apiAddContact failed") + + private companion object { + const val NOTES = "Private notes" + const val TEXT = "hello from share" + const val CAPTION = "the notes, shared from another app" + const val PDF_NAME = "share-notes.pdf" + const val PDF_CONTENT = "Notes shared into SimpleX from another app" + const val NO_FILES = "nofiles" + const val NO_LINKS = "nolinks" + const val SHARE_MESSAGE = "Share message…" + const val SHARE_MEDIA = "Share media…" + const val SHARE_FILE = "Share file…" + const val STOPPED = "Chat is stopped" + const val START_HINT = "You can start chat via app Settings / Database or by restarting the app." + const val INVALID_PATH = "You shared an invalid file path. Report the issue to the app developers." + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/SystemIntegrationHelpers.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/SystemIntegrationHelpers.kt new file mode 100644 index 0000000000..d06b2e09db --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/SystemIntegrationHelpers.kt @@ -0,0 +1,95 @@ +package chat.simplex.app.ui.android + +import android.app.Notification +import android.app.NotificationManager +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import chat.simplex.app.MainActivity +import chat.simplex.app.model.NtfManager +import chat.simplex.app.ui.harness.App +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTestApp +import chat.simplex.app.ui.harness.chatData +import chat.simplex.app.ui.harness.chatRunningInCore +import chat.simplex.common.helpers.APPLICATION_ID +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.isAppOnForeground +import chat.simplex.common.ui.Ui +import java.io.File + +internal fun UiScope.deliverToApp(intent: Intent) { + InstrumentationRegistry.getInstrumentation().targetContext.startActivity( + Intent(intent).setClass(androidAppContext, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + rule.waitForIdle() +} + +internal fun contentUri(file: File): Uri = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", file) + +internal fun shareTextIntent(text: String): Intent = + Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text) + +internal fun shareStreamIntent(type: String, uri: Uri, text: String? = null): Intent = + Intent(Intent.ACTION_SEND).setType(type).putExtra(Intent.EXTRA_STREAM, uri).apply { if (text != null) putExtra(Intent.EXTRA_TEXT, text) } + +internal fun shareStreamsIntent(type: String, uris: List): Intent = + Intent(Intent.ACTION_SEND_MULTIPLE).setType(type).putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + +internal fun viewIntent(link: String?): Intent = + Intent(Intent.ACTION_VIEW).apply { if (link != null) data = Uri.parse(link) } + +internal fun UiScope.shareListTitle(): String? = + Ui.Share.Bar.part("title").let { if (exists(it)) textsOf(it).firstOrNull() else null } + +internal fun UiScope.awaitShareList(title: String) = + poll(what = "the share list titled `$title` in place of the chat list") { shareListTitle() == title && !exists(Ui.ChatList.Search) } + +internal fun UiScope.stopChatFromChatData(app: App) { + chatData(app).management() + click(Ui.Database.RunChat) + poll(what = "the Stop chat? alert with Stop") { seesText("Stop chat?") && "Stop" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the chat stopped on screen and in the core") { seesText("Chat is stopped") && chatModel.chatRunning.value == false && !chatRunningInCore() } +} + +internal class PostedNotification(val id: Int, val notification: Notification) { + val title: String? get() = notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() + val text: String? get() = notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() + override fun toString() = "#$id `$title`: `$text`" +} + +private val notificationManager: NotificationManager get() = androidAppContext.getSystemService(NotificationManager::class.java) + +internal fun messageNotifications(): List = + notificationManager.activeNotifications + .filter { it.notification.channelId == NtfManager.MessageChannel && (it.notification.flags and Notification.FLAG_GROUP_SUMMARY) == 0 } + .map { PostedNotification(it.id, it.notification) } + +internal fun messageSummaryShown(): Boolean = + notificationManager.activeNotifications.any { + it.notification.channelId == NtfManager.MessageChannel && it.notification.group == NtfManager.MessageGroup && (it.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0 + } + +internal fun clearNotificationsViaApi() = notificationManager.cancelAll() + +internal fun UiScope.awaitForeground() = poll(what = "the app in the foreground") { isAppOnForeground } + +internal fun runningMainActivities(): List { + var live = emptyList() + UiTestApp.onMain { + val monitor = ActivityLifecycleMonitorRegistry.getInstance() + live = Stage.values().filter { it != Stage.DESTROYED }.flatMap { monitor.getActivitiesInStage(it) }.filterIsInstance() + } + return live +} + +internal fun UiScope.finishMainActivities() { + val live = runningMainActivities() + UiTestApp.onMain { live.forEach { it.finish() } } + poll(what = "every MainActivity to be destroyed") { runningMainActivities().isEmpty() } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/SystemNotificationsAndroidTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/SystemNotificationsAndroidTest.kt new file mode 100644 index 0000000000..5c3908fd09 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/SystemNotificationsAndroidTest.kt @@ -0,0 +1,104 @@ +package chat.simplex.app.ui.android + +import android.app.Notification +import chat.simplex.app.model.NtfManager +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitRowText +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.quiesce +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.MsgFilter +import chat.simplex.common.platform.chatModel +import org.junit.Test + +class SystemNotificationsAndroidTest : UiTest() { + @Test fun sysNtf_foreground_onlyForChatsNotOpen() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + connect(cath) + val bobId = alice.chatList.chatId("bob") + alice.chatList.row("cath") + quiesce() + awaitForeground() + clearNotificationsViaApi() + val chat = alice.chatList.open("bob") + bob sends "@alice $TO_OPEN_CHAT" + chat.itemId(TO_OPEN_CHAT) + cath sends "@alice $FROM_CATH" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "cath's message notification while bob's chat is open") { messageNotifications().any { it.title == "cath" } } + val fromCath = messageNotifications().single { it.title == "cath" } + check(fromCath.text == FROM_CATH && fromCath.notification.group == NtfManager.MessageGroup && (fromCath.notification.flags and Notification.FLAG_AUTO_CANCEL) != 0 && messageSummaryShown()) { + "cath's notification: $fromCath, group ${fromCath.notification.group}, flags ${fromCath.notification.flags}, summary ${messageSummaryShown()}" + } + check(chatModel.chatId.value == bobId && messageNotifications().none { it.title == "bob" }) { "bob's message to his open chat was notified: ${messageNotifications()}" } + + backToChatList() + alice.chatList.rowMenu("cath", "ntfs.None", "Mute") + poll(what = "cath muted and her notification cancelled") { + alice.chatList.chat("cath").chatInfo.chatSettings?.enableNtfs == MsgFilter.None && messageNotifications().none { it.title == "cath" } + } + cath sends "@alice $MUTED" + awaitRowText(alice, "cath", MUTED) + bob sends "@alice $AFTER_CLOSE" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's notification now that his chat is closed") { messageNotifications().any { it.title == "bob" } } + check(messageNotifications().map { it.title to it.text } == listOf("bob" to AFTER_CLOSE)) { "notifications after muting cath: ${messageNotifications()}" } + + alice.chatList.open("bob") + poll(what = "bob's notification cancelled once his chat is read") { messageNotifications().none { it.title == "bob" } } + } + + @Test fun sysNtf_tapOpensChat() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val bobId = alice.chatList.chatId("bob") + quiesce() + awaitForeground() + clearNotificationsViaApi() + try { + bob sends "@alice $HELLO" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's message notification") { messageNotifications().any { it.title == "bob" && it.text == HELLO } } + check(chatModel.chatId.value == null && runningMainActivities().size == 1) { "before the tap: open chat ${chatModel.chatId.value}, activities ${runningMainActivities()}" } + messageNotifications().single { it.title == "bob" }.notification.contentIntent.send() + poll(what = "MainActivity recreated with bob's chat open") { runningMainActivities().size == 1 && chatModel.chatId.value == bobId } + ChatScreen(this).itemId(HELLO) + poll(what = "bob's notification cancelled once `$HELLO` is read") { messageNotifications().none { it.title == "bob" } } + } finally { + finishMainActivities() + } + } + + @Test fun sysNtf_contactRequest_acceptAction() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = alice.userPicker().createAddress() + alice.chatList.row("Private notes") + awaitForeground() + clearNotificationsViaApi() + bob sends "/c $address" + bob expects "connection request sent!" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's contact request notification") { messageNotifications().any { it.title == "bob" && it.text == REQUEST } } + val request = messageNotifications().single { it.text == REQUEST } + val actions = request.notification.actions.orEmpty() + check(actions.map { it.title.toString() } == listOf("Accept") && request.notification.getLargeIcon() != null) { + "request notification actions ${actions.map { it.title }}, large icon ${request.notification.getLargeIcon() != null}" + } + check(chatModel.chats.value.none { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.displayName == "bob" && c.ready } == true }) { "bob is a connected contact before Accept" } + actions.single().actionIntent.send() + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the request notification replaced by bob's Connected notification") { + messageNotifications().let { ns -> ns.none { it.text == REQUEST } && ns.any { it.title == "bob" && it.text == "Connected" } } + } + check(chatModel.chatId.value == null) { "Accept in the notification opened a chat: ${chatModel.chatId.value}" } + } + + private companion object { + const val TO_OPEN_CHAT = "sent to the open chat" + const val FROM_CATH = "hello from cath" + const val MUTED = "sent to a muted chat" + const val AFTER_CLOSE = "sent after the chat closed" + const val HELLO = "hello" + const val REQUEST = "New contact request" + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/UseFromDesktopAndroidTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/UseFromDesktopAndroidTest.kt new file mode 100644 index 0000000000..c10c25e2a1 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/android/UseFromDesktopAndroidTest.kt @@ -0,0 +1,73 @@ +package chat.simplex.app.ui.android + +import chat.simplex.app.ui.harness.Bridge +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitNewRemoteHost +import chat.simplex.app.ui.harness.awaitRemoteHostCode +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.endDesktopSessionViaApi +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.linkToDesktop +import chat.simplex.app.ui.harness.setDeviceName +import chat.simplex.app.ui.harness.startRemoteHost +import chat.simplex.app.ui.harness.useFromDesktop +import chat.simplex.common.model.CIDirection +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.UIRemoteCtrlSessionState +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Test + +class UseFromDesktopAndroidTest : UiTest() { + @Test fun useFromDesktop_pasteVerifyConfirm_connected() = uiTest { + val alice = app("alice") + enableDeveloperToolsViaApi() + val desktop = cli("desktop") + desktop.setDeviceName(DESKTOP) + val invitation = desktop.startRemoteHost(Bridge.remoteCtrlPort()) + setClipboardText(invitation) + val screen = useFromDesktop(alice) + val androidName = screen.deviceName() + check(!seesText(invitation)) { "the desktop address is shown before pasting it" } + screen.paste(invitation) + screen.connect() + val code = desktop.awaitRemoteHostCode() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Verify connection` with `$DESKTOP (new)` and the controller's session code `${code.take(23)}`") { + seesText("Verify connection") && seesText("$DESKTOP (new)") && seesText(code.take(23)) && exists(Ui.Remote.Confirm) + } + check(chatModel.remoteCtrlSession.value?.sessionState !is UIRemoteCtrlSessionState.Connected) { "the session is connected before Confirm" } + click(Ui.Remote.Confirm) + assertEquals("the host name the controller shows", androidName, desktop.awaitNewRemoteHost()) + screen.awaitConnected(DESKTOP, code) + endDesktopSessionViaApi(desktop) + } + + @Test fun useFromDesktop_controllerSendsAsAndroidUser() = uiTest { + val alice = app("alice") + enableDeveloperToolsViaApi() + val bob = cli("bob") + connect(bob) + val desktop = cli("desktop") + desktop.setDeviceName(DESKTOP) + linkToDesktop(alice, desktop, DESKTOP, Bridge.remoteCtrlPort()) + desktop sends "/contacts" + desktop.eventually("bob") + desktop sends "@bob hi from desktop" + bob.eventuallyMessage("alice> hi from desktop", CONNECT_TIMEOUT_MS) + endDesktopSessionViaApi(desktop) + goBack() + poll(what = "`Connect to desktop` to close over alice's chat list") { !exists(Ui.Remote.DeviceName) && exists(Ui.ChatList.Search) } + alice.chatList.open("bob") + poll(what = "the message sent from the desktop as alice's sent item in the chat with bob") { + seesText("hi from desktop", substring = true) && chatModel.chatsContext.chatItems.value.any { it.content.text == "hi from desktop" && it.chatDir is CIDirection.DirectSnd } + } + } + + private fun enableDeveloperToolsViaApi() = appPrefs.developerTools.set(true) + + private companion object { + const val DESKTOP = "My desktop" + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/CliPeer.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/CliPeer.kt index 7236b92cd1..3f4ff3f6ee 100644 --- a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/CliPeer.kt +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/CliPeer.kt @@ -35,12 +35,56 @@ object Bridge { } fun servers(): Pair = call("servers").first().split(" ").let { it[1] to it[2] } + + fun remoteCtrlPort(): Int = call("remotePort").first().removePrefix("ok ").toInt() +} + +object TestServers { + fun start(name: String): String = Bridge.call("server start $name").first().removePrefix("ok ") + + fun stop(name: String) { Bridge.call("server stop $name") } + + fun reset(name: String): String = Bridge.call("server reset $name").first().removePrefix("ok ") + + fun address(name: String): String = Bridge.call("server address $name").first().removePrefix("ok ") + + fun restoreBaseline() { Bridge.call("server baseline") } +} + +object SocksProxy { + private var started: Int? = null + + val port: Int get() = started ?: error("the SOCKS proxy is not started; SocksProxy.start() starts it") + + fun start() { + if (started == null) started = Bridge.call("socks start").first().removePrefix("ok ").toInt() + } + + fun connections(): List = + Bridge.call("socks connections").map { line -> + val (host, port, username) = line.split(" ", limit = 3) + SocksConnection(host, port.toInt(), username.takeIf { it != "-" }) + } + + fun stop() { + if (started == null) return + started = null + Bridge.call("socks stop") + } } class CliPeer(name: String, private val flags: PeerFlags, waiter: Waiter) : PeerLines(name, waiter) { + private var relayLink: String? = null + + val relayAddress: String + get() = relayLink ?: error("$name printed no chat relay address; start it with PeerFlags(relay = true) on a new database") + fun start() { - val f = listOfNotNull("reactions".takeIf { flags.reactions }, "markRead".takeIf { flags.markRead }, "fullLinks".takeIf { flags.fullLinks }) - Bridge.call("start $name ${f.joinToString(",")}") + val f = listOfNotNull( + "reactions".takeIf { flags.reactions }, "markRead".takeIf { flags.markRead }, "fullLinks".takeIf { flags.fullLinks }, "relay".takeIf { flags.relay }, + "smp=${flags.smpServer}".takeIf { flags.smpServer != PeerFlags().smpServer }, "xftp=${flags.xftpServer}".takeIf { flags.xftpServer != PeerFlags().xftpServer } + ) + Bridge.call("start $name ${f.joinToString(",")}").first().removePrefix("ok").trim().takeIf { it.isNotEmpty() }?.let { relayLink = it } } fun stop() { runCatching { Bridge.call("stop $name") } } diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTest.kt index 16a3647a1c..baf41fbd9c 100644 --- a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTest.kt +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTest.kt @@ -23,6 +23,9 @@ import android.app.Instrumentation import android.content.Intent import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.core.content.FileProvider +import chat.simplex.common.helpers.APPLICATION_ID +import java.io.File abstract class UiTest { @get:Rule val rule: ComposeTestRule = createEmptyComposeRule() @@ -83,18 +86,36 @@ class UiScope(val rule: ComposeTestRule) { return App(this) } - fun restart(): App { - UiTestApp.restartCore() - UiTestApp.onMain { CommonPrefs.reload() } - poll(timeoutMs = 60_000, what = "the core to start again with chat running") { + fun restart( + ready: () -> Boolean = { val m = chat.simplex.common.platform.chatModel m.chatDbStatus.value != null && !m.ctrlInitInProgress.value && m.chatRunning.value == true && m.currentUser.value != null && m.users.isNotEmpty() - } + }, + what: String = "the core to start again with chat running" + ): App { + UiTestApp.restartCore() + UiTestApp.onMain { CommonPrefs.reload() } + poll(timeoutMs = 60_000, what = what) { ready() } Timing.phase("close") { close() } Timing.phase("launch") { launch() } return App(this) } + fun exchangeDir(): File = File(androidAppContext.filesDir, "uitest-exchange").apply { mkdirs() } + + fun saveFileAs(file: File, action: () -> Unit) = answerIntent(listOf(Intent.ACTION_CREATE_DOCUMENT), file, action) + + fun openFile(file: File, action: () -> Unit) = answerIntent(listOf(Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK), file, action) + + private fun answerIntent(intentActions: List, file: File, action: () -> Unit) { + val uri = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", file) + val result = Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(uri)) + intentActions.forEach { Intents.intending(IntentMatchers.hasAction(it)).respondWith(result) } + val before = Intents.getIntents().count { it.action in intentActions } + action() + poll(what = "the app to ask for a file with ${intentActions.joinToString(" or ")}") { Intents.getIntents().count { it.action in intentActions } > before } + } + fun keyboardShown(): Boolean { var shown = false UiTestApp.onMain { @@ -128,6 +149,7 @@ class UiScope(val rule: ComposeTestRule) { fun finish() { peers.forEach { it.dispose() } + runCatching { SocksProxy.stop() } Timing.phase("close") { close() } Timing.phase("drain") { UiTestApp.drain() } } @@ -155,6 +177,7 @@ class UiScope(val rule: ComposeTestRule) { val deadline = start + timeoutMs var busyAtDeadline: Set? = null var lastError: Throwable? = null + var workEnded = false while (true) { rule.mainClock.advanceTimeBy(48, ignoreFrameDuration = true) rule.waitForIdle() @@ -165,10 +188,12 @@ class UiScope(val rule: ComposeTestRule) { } val now = System.currentTimeMillis() val busy = if (now > deadline) busyAtDeadline ?: UiIdle.inFlight().map { it.id }.toSet().also { busyAtDeadline = it } else emptySet() - if (now > deadline && (UiIdle.inFlight().none { it.id in busy } || now > deadline + EXTENSION_MS)) { + val idle = now > deadline && UiIdle.inFlight().none { it.id in busy } + if (now > deadline && ((idle && workEnded) || now > deadline + EXTENSION_MS)) { val shot = if (screenshot) FailureShots.take(what) else null throw AssertionError("timed out after $timeoutMs ms waiting for $what; ${lastError?.let { "last condition error: $it; " } ?: ""}in flight: ${UiIdle.inFlight().joinToString { it.what }}; screenshot: $shot; visible texts: ${texts().take(60)}") } + workEnded = idle Thread.sleep(50) } } @@ -197,6 +222,31 @@ class UiScope(val rule: ComposeTestRule) { } } + fun scrollChatTowardOlder(pages: Int = 1): Boolean = scrollChat(pages, towardOlder = true) + + fun scrollChatTowardNewer(pages: Int = 1): Boolean = scrollChat(pages, towardOlder = false) + + private fun scrollChat(pages: Int, towardOlder: Boolean): Boolean { + val list = rule.onNode(SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollToIndex) and SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollBy) and hasAnyDescendant(chatItemNode)) + repeat(pages) { + val items = rule.onAllNodes(chatItemNode).fetchSemanticsNodes() + val tracked = (if (towardOlder) items.minByOrNull { it.boundsInRoot.top } else items.maxByOrNull { it.boundsInRoot.bottom }) + ?: throw AssertionError("no chat items in view to scroll the chat") + val tag = tracked.config[SemanticsProperties.TestTag] + val before = tracked.boundsInRoot + val node = list.fetchSemanticsNode() + val dy = node.boundsInRoot.height * CHAT_SCROLL_PAGE + rule.runOnUiThread { node.config[SemanticsActions.ScrollBy].action?.invoke(0f, if (towardOlder) dy else -dy) } + settle() + val after = rule.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().firstOrNull()?.boundsInRoot + if (after == before) return false + if (after == null || (if (towardOlder) after.top <= before.top else after.bottom >= before.bottom)) { + throw AssertionError("scrolling the chat toward ${if (towardOlder) "older" else "newer"} messages moved $tag the other way: $before -> $after") + } + } + return true + } + fun waitStable(key: UiKey) { var last: androidx.compose.ui.geometry.Rect? = null try { @@ -246,13 +296,16 @@ class UiScope(val rule: ComposeTestRule) { rule.waitForIdle() } - private fun isMessageField(key: UiKey) = key.tag == Ui.Chat.Message.tag + private fun isMessageField(key: UiKey) = key.id == Ui.Chat.Message.id + + private fun messageEditText(key: UiKey) = + Espresso.onView(org.hamcrest.CoreMatchers.allOf(ViewMatchers.isAssignableFrom(EditText::class.java), ViewMatchers.withTagValue(org.hamcrest.CoreMatchers.equalTo(key.tag)))) private fun setMessageField(key: UiKey, value: String) { waitFor(key) waitStable(key) settle() - Espresso.onView(ViewMatchers.isAssignableFrom(EditText::class.java)).perform(CommitText(value)) + messageEditText(key).perform(CommitText(value)) poll(what = "the message field to hold `$value`") { editableText(key) == value } } @@ -285,7 +338,7 @@ class UiScope(val rule: ComposeTestRule) { } fun pressEnter(key: UiKey) { - if (isMessageField(key)) click(Ui.Chat.Send) else rule.onNode(matcher(key)).performImeAction() + if (isMessageField(key)) click(key.part("send")) else rule.onNode(matcher(key)).performImeAction() rule.waitForIdle() } @@ -295,13 +348,21 @@ class UiScope(val rule: ComposeTestRule) { fun editableText(key: UiKey): String? { if (isMessageField(key)) { var t: String? = null - Espresso.onView(ViewMatchers.isAssignableFrom(EditText::class.java)).check { v, _ -> t = (v as? EditText)?.text?.toString() } + messageEditText(key).check { v, _ -> t = (v as? EditText)?.text?.toString() } return t } return rule.onAllNodes((matcher(key) or hasAnyAncestor(matcher(key))) and SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText), useUnmergedTree = true) .fetchSemanticsNodes().firstOrNull()?.config?.get(SemanticsProperties.EditableText)?.text } + fun hintOf(key: UiKey): String? { + if (!isMessageField(key)) return if (editableText(key).isNullOrEmpty()) textsOf(key).firstOrNull() else null + waitFor(key) + var hint: String? = null + messageEditText(key).check { v, _ -> hint = (v as? EditText)?.takeIf { it.text.isNullOrEmpty() }?.hint?.toString() } + return hint + } + fun textsOf(key: UiKey): List { waitFor(key) val own = rule.onNode(matcher(key)).fetchSemanticsNode().config.getOrElseNullable(SemanticsProperties.Text) { null }.orEmpty().map { it.text } @@ -350,6 +411,13 @@ class UiScope(val rule: ComposeTestRule) { val inner = if (i.action == Intent.ACTION_CHOOSER) i.getParcelableExtra(Intent.EXTRA_INTENT) as? Intent else i inner?.getStringExtra(Intent.EXTRA_TEXT) } ?: (androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager).primaryClip?.getItemAt(0)?.text?.toString() + + fun setClipboardText(text: String?) = UiTestApp.onMain { + val cm = androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + if (text != null) cm.setPrimaryClip(android.content.ClipData.newPlainText("uitest", text)) + else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) cm.clearPrimaryClip() + else error("clearing the clipboard needs Android 9") + } } object FailureShots { @@ -379,6 +447,9 @@ object Timing { } private const val SETTLE_MS = 500L +private const val CHAT_SCROLL_PAGE = 0.8f +private val CHAT_ITEM_TAG = "${Ui.Chat.Item(0).id}[" +private val chatItemNode = SemanticsMatcher("a chat item") { it.config.getOrElseNullable(SemanticsProperties.TestTag) { null }?.startsWith(CHAT_ITEM_TAG) == true } private const val KEYBOARD_SHOW_MS = 1_500L private const val END_WORK_MS = 30_000L private const val EXTENSION_MS = 60_000L diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTestApp.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTestApp.kt index d1da0df273..60a91e39cc 100644 --- a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTestApp.kt +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTestApp.kt @@ -75,6 +75,7 @@ object UiTestApp { Timing.phase("reset.stop running=${chatModel.chatRunning.value}") { stopCore() } Timing.phase("reset.delete") { deleteChatDatabaseFilesAndState() } Timing.phase("reset.prefs") { PrefStores.restore() } + Timing.phase("reset.serversBaseline") { TestServers.restoreBaseline() } androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand("cmd statusbar collapse").close() if (chat.simplex.app.SimplexService.isServiceStarted) Timing.phase("reset.ntfs") { onMain { chat.simplex.common.views.usersettings.changeNotificationsMode(chat.simplex.common.model.NotificationsMode.OFF, chatModel) } @@ -111,7 +112,12 @@ object UiTestApp { } private fun stopCore() { - waitUntil(10_000, "ctrlInitInProgress to clear") { !chatModel.ctrlInitInProgress.value } + val deadline = System.currentTimeMillis() + 10_000 + while (chatModel.ctrlInitInProgress.value && System.currentTimeMillis() < deadline) Thread.sleep(50) + if (chatModel.ctrlInitInProgress.value) { + android.util.Log.w("UITEST", "ctrlInitInProgress still set after 10 s (left by a failed test); clearing it to reset") + chatModel.ctrlInitInProgress.value = false + } runBlocking { val ctrl = chatController.getChatCtrl() if (ctrl != null && ctrl != -1L && chatModel.chatRunning.value == true) chatController.apiStopChat() diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 96a6a1e14d..dc492b8ef3 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -36,8 +36,9 @@ import chat.simplex.common.R import chat.simplex.common.helpers.toURI import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel -import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.ui +import chat.simplex.common.ui.uiTestHooksEnabled import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -62,6 +63,7 @@ actual fun PlatformTextField( onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, focusRequester: FocusRequester?, + uiKey: UiKey?, onDone: () -> Unit, ) { val cs = composeState.value @@ -98,7 +100,7 @@ actual fun PlatformTextField( } val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - AndroidView(modifier = Modifier.ui(Ui.Chat.Message), factory = { context -> + AndroidView(modifier = Modifier.ui(uiKey), factory = { context -> val editText = @SuppressLint("AppCompatCustomView") object: EditText(context) { override fun setOnReceiveContentListener( mimeTypes: Array?, @@ -137,6 +139,7 @@ actual fun PlatformTextField( editText.background = ColorDrawable(Color.Transparent.toArgb()) editText.textDirection = if (isRtl) EditText.TEXT_DIRECTION_LOCALE else EditText.TEXT_DIRECTION_ANY_RTL editText.setPaddingRelative(paddingStart, paddingTop, paddingEnd, paddingBottom) + if (uiTestHooksEnabled) editText.tag = uiKey?.tag editText.setText(cs.message.text) editText.setSelection(cs.message.selection.start, cs.message.selection.end) editText.hint = placeholder diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index e16f99bf56..f0d0d711e7 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -13,6 +13,7 @@ import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.FileChooserLauncher import chat.simplex.common.platform.saveImage +import chat.simplex.common.ui.Ui import chat.simplex.common.views.helpers.* import chat.simplex.res.* import com.google.accompanist.permissions.PermissionStatus @@ -28,7 +29,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) - ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { + ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), uiKey = Ui.Chat.ItemMenu(cItem.id, "save"), onClick = { when (cItem.content.msgContent) { is MsgContent.MCImage -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.status == PermissionStatus.Granted) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt index a81d9cd1ee..0c5a8dceec 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.android.kt @@ -12,7 +12,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.theme.SimplexGreen +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @@ -26,6 +28,7 @@ actual fun SavePassphraseSetting( minHeight: Dp, enabled: Boolean, smallPadding: Boolean, + uiKey: UiKey?, onCheckedChange: (Boolean) -> Unit, ) { SectionItemView(minHeight = minHeight) { @@ -48,6 +51,7 @@ actual fun SavePassphraseSetting( DefaultSwitch( checked = useKeychain, onCheckedChange = onCheckedChange, + modifier = Modifier.ui(uiKey), enabled = enabled ) } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt index 4c980acf91..9a9b690bf9 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.android.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.views.newchat.ActionButton import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @@ -11,19 +13,19 @@ import dev.icerock.moko.resources.compose.stringResource @Composable actual fun ChooseAttachmentButtons(attachmentOption: MutableState, hide: () -> Unit) { - ActionButton(Modifier.fillMaxWidth(0.25f), null, stringResource(MR.strings.use_camera_button), icon = painterResource(MR.images.ic_camera_enhance)) { + ActionButton(Modifier.fillMaxWidth(0.25f).ui(Ui.Chat.Attach("camera")), null, stringResource(MR.strings.use_camera_button), icon = painterResource(MR.images.ic_camera_enhance)) { attachmentOption.value = AttachmentOption.CameraPhoto hide() } - ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) { + ActionButton(Modifier.fillMaxWidth(0.33f).ui(Ui.Chat.Attach("image")), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) { attachmentOption.value = AttachmentOption.GalleryImage hide() } - ActionButton(Modifier.fillMaxWidth(0.50f), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) { + ActionButton(Modifier.fillMaxWidth(0.50f).ui(Ui.Chat.Attach("video")), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) { attachmentOption.value = AttachmentOption.GalleryVideo hide() } - ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) { + ActionButton(Modifier.fillMaxWidth(1f).ui(Ui.Chat.Attach("file")), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) { attachmentOption.value = AttachmentOption.File hide() } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt index e5ae2b8a9b..b12c6d9c11 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.platform.LocalContext import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel @@ -105,11 +106,11 @@ fun AppearanceScope.AppearanceLayout( } // } - SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI) { enabled -> + SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI, uiKey = Ui.Appearance.OneHandUI) { enabled -> if (enabled) appPrefs.chatBottomBar.set(true) } if (remember { appPrefs.oneHandUI.state }.value) { - SettingsPreferenceItem(icon = null, stringResource(MR.strings.chat_bottom_bar), ChatModel.controller.appPrefs.chatBottomBar) + SettingsPreferenceItem(icon = null, stringResource(MR.strings.chat_bottom_bar), ChatModel.controller.appPrefs.chatBottomBar, uiKey = Ui.Appearance.ChatBottomBar) } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt index 4da9bf03b0..b77bc5bf52 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.android.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.views.helpers.* import chat.simplex.res.* import com.jakewharton.processphoenix.ProcessPhoenix @@ -24,7 +25,7 @@ actual fun AdvancedSettingsAppSection( withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, ) { SectionView { - SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) }) + SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) }, uiKey = Ui.Settings.DeveloperTools) } } @@ -50,7 +51,7 @@ private fun shutdownAppAlert() { text = generalGetString(MR.strings.shutdown_alert_desc), buttons = { Column { - SectionItemView({ AlertManager.shared.hideAlert() }) { + SectionItemView({ AlertManager.shared.hideAlert() }, uiKey = Ui.Alert.Dismiss(Ui.AlertSource.Shared)) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center) } SectionItemView({ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index ce1152de6a..e2a7eb7a24 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -22,6 +22,7 @@ import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.CreateFirstProfile import chat.simplex.common.views.helpers.SimpleButton @@ -125,6 +126,7 @@ fun MainScreen() { SimpleButton( stringResource(MR.strings.auth_unlock), icon = painterResource(MR.images.ic_lock), + uiKey = Ui.Lock.Unlock, click = { AppLock.laFailed.value = false AppLock.runAuthenticate() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index 6b301b9df4..5c378c5e74 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle +import chat.simplex.common.ui.UiKey import chat.simplex.common.views.chat.ComposeMessage import chat.simplex.common.views.chat.ComposeState import java.net.URI @@ -22,5 +23,6 @@ expect fun PlatformTextField( onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, focusRequester: FocusRequester? = null, + uiKey: UiKey? = null, onDone: () -> Unit, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/Ui.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/Ui.kt index 54209071e3..35864844ac 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/Ui.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/Ui.kt @@ -3,6 +3,8 @@ package chat.simplex.common.ui import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import chat.simplex.common.model.ChatFeature +import chat.simplex.common.model.GroupFeature @Immutable data class UiKey(val id: String, val arg: Any? = null) { @@ -53,25 +55,127 @@ object Ui { object NewChat { val CreateLink = UiKey("newChat.createLink") val CreateGroup = UiKey("newChat.createGroup") + val CreateChannel = UiKey("newChat.createChannel") + val PasteLink = UiKey("newChat.pasteLink") + val Search = UiKey("newChat.search") + fun Contact(chatId: String) = UiKey("newChat.contact", chatId) + fun ContactMenu(chatId: String, action: String) = UiKey("newChat.contact.menu.$action", chatId) + val Archived = UiKey("newChat.archived") + val Favorites = UiKey("newChat.favorites") } object UserPicker { fun User(userId: Long) = UiKey("userPicker.user", userId) val Address = UiKey("userPicker.address") + val ChatPreferences = UiKey("userPicker.chatPreferences") + val LinkMobile = UiKey("userPicker.linkMobile") val Settings = UiKey("userPicker.settings") val Profiles = UiKey("userPicker.profiles") val Sheet = UiKey("userPicker.sheet") + val ColorMode = UiKey("userPicker.colorMode") + val UseFromDesktop = UiKey("userPicker.useFromDesktop") + fun Device(remoteHostId: Long?) = UiKey("userPicker.device", remoteHostId ?: "local") } object Settings { val Appearance = UiKey("settings.appearance") val Help = UiKey("settings.help") val Privacy = UiKey("settings.privacy") + val Database = UiKey("settings.database") + val Network = UiKey("settings.network") + val Calls = UiKey("settings.calls") + val Version = UiKey("settings.version") + val DeveloperTools = UiKey("settings.developerTools") + val Migrate = UiKey("settings.migrate") + } + object Notifications { + val Mode = UiKey("notifications.mode") + } + object Network { + fun Operator(operatorId: Long) = UiKey("network.operator", operatorId) + val YourServers = UiKey("network.yourServers") + val UseSocks = UiKey("network.useSocks") + val SocksSettings = UiKey("network.socksSettings") + val Advanced = UiKey("network.advanced") + val SaveServers = UiKey("network.saveServers") + } + object Servers { + fun Server(server: String) = UiKey("servers.server", server) + val Add = UiKey("servers.add") + val TestAll = UiKey("servers.testAll") + val Address = UiKey("servers.address") + val Test = UiKey("servers.test") + val Use = UiKey("servers.use") + val Delete = UiKey("servers.delete") + } + object Operator { + val Info = UiKey("operator.info") + val Use = UiKey("operator.use") + val AcceptConditions = UiKey("operator.acceptConditions") + } + object AdvancedNetwork { + val ProxyMode = UiKey("advancedNetwork.proxyMode") + val ProxyFallback = UiKey("advancedNetwork.proxyFallback") + fun Setting(name: String) = UiKey("advancedNetwork.setting.$name") + val KeepAlive = UiKey("advancedNetwork.keepAlive") + val SessionMode = UiKey("advancedNetwork.sessionMode") + val Reset = UiKey("advancedNetwork.reset") + val Save = UiKey("advancedNetwork.save") + } + object Socks { + val Host = UiKey("socks.host") + val Port = UiKey("socks.port") + val OnionHosts = UiKey("socks.onionHosts") + val Save = UiKey("socks.save") + val Reset = UiKey("socks.reset") } object Privacy { val More = UiKey("privacy.more") val ShowLastMessages = UiKey("privacy.showLastMessages") + val Lock = UiKey("privacy.lock") + val LinkPreviews = UiKey("privacy.linkPreviews") + val AcceptImages = UiKey("privacy.acceptImages") + val BlurMedia = UiKey("privacy.blurMedia") + val ReceiptsContacts = UiKey("privacy.receiptsContacts") + val ReceiptsGroups = UiKey("privacy.receiptsGroups") + val SanitizeLinks = UiKey("privacy.sanitizeLinks") + val AutoAcceptMemberContacts = UiKey("privacy.autoAcceptMemberContacts") + val AutoAcceptGroupInvitations = UiKey("privacy.autoAcceptGroupInvitations") + val SaveDraft = UiKey("privacy.saveDraft") + val EncryptLocalFiles = UiKey("privacy.encryptLocalFiles") + } + object DeliveryReceipts { + val Enable = UiKey("deliveryReceipts.enable") + val Skip = UiKey("deliveryReceipts.skip") + } + object Lock { + val Enable = UiKey("lock.enable") + val Delay = UiKey("lock.delay") + val ChangePasscode = UiKey("lock.changePasscode") + val SelfDestruct = UiKey("lock.selfDestruct") + val SelfDestructName = UiKey("lock.selfDestructName") + val SelfDestructInfo = UiKey("lock.selfDestructInfo") + val ChangeSelfDestruct = UiKey("lock.changeSelfDestruct") + val Unlock = UiKey("lock.unlock") + } + object Passcode { + fun Digit(d: Int) = UiKey("passcode.digit", d) + val Backspace = UiKey("passcode.backspace") + val Clear = UiKey("passcode.clear") + val Submit = UiKey("passcode.submit") + val Cancel = UiKey("passcode.cancel") + val Entered = UiKey("passcode.entered") } object Help { val WhatsNew = UiKey("help.whatsNew") + val HowToUse = UiKey("help.howToUse") + val About = UiKey("help.about") + val Email = UiKey("help.email") + val Contribute = UiKey("help.contribute") + val Star = UiKey("help.star") + } + object DevTools { + val Console = UiKey("devTools.console") + val ResetHints = UiKey("devTools.resetHints") + val ShowOptions = UiKey("devTools.showOptions") } object WhatsNew { val Ok = UiKey("whatsNew.ok") @@ -79,6 +183,11 @@ object Ui { } object ServersSummary { val ShowPercentage = UiKey("serversSummary.showPercentage") + val ResetStats = UiKey("serversSummary.resetStats") + val ReconnectAll = UiKey("serversSummary.reconnectAll") + val Details = UiKey("serversSummary.details") + fun SmpServer(host: String) = UiKey("serversSummary.smpServer", host) + val UserCategory = UiKey("serversSummary.userCategory") } object Onboarding { val GetStarted = UiKey("onboarding.getStarted") @@ -106,22 +215,60 @@ object Ui { object Remote { val DeviceName = UiKey("remote.deviceName") val ShareLink = UiKey("remote.shareLink") + val AddMobile = UiKey("remote.addMobile") + val PasteAddress = UiKey("remote.pasteAddress") + val Connect = UiKey("remote.connect") + val Confirm = UiKey("remote.confirm") + val Disconnect = UiKey("remote.disconnect") } object Appearance { val ColorMode = UiKey("appearance.colorMode") val FontScale = UiKey("appearance.fontScale") val Zoom = UiKey("appearance.zoom") + val Language = UiKey("appearance.language") + val OneHandUI = UiKey("appearance.oneHandUI") + val DarkModeColors = UiKey("appearance.darkModeColors") + val ApplyTo = UiKey("appearance.applyTo") + fun Wallpaper(preset: String) = UiKey("appearance.wallpaper", preset) + val WallpaperImage = UiKey("appearance.wallpaperImage") + val RemoveImage = UiKey("appearance.removeImage") + val Customize = UiKey("appearance.customize") + val Accent = UiKey("appearance.accent") + val ColorHex = UiKey("appearance.colorHex") + val ResetColors = UiKey("appearance.resetColors") + val ExportTheme = UiKey("appearance.exportTheme") + val ImportTheme = UiKey("appearance.importTheme") + val ChatBottomBar = UiKey("appearance.chatBottomBar") } object Address { val Create = UiKey("address.create") val Share = UiKey("address.share") + val Delete = UiKey("address.delete") + val Business = UiKey("address.business") + val Settings = UiKey("address.settings") + val AutoAccept = UiKey("address.autoAccept") + val WelcomeMessage = UiKey("address.welcomeMessage") + val SaveSettings = UiKey("address.saveSettings") + val ShareWithContacts = UiKey("address.shareWithContacts") + val ShareViaChat = UiKey("address.shareViaChat") + val CreateLink = UiKey("address.createLink") + val LearnMore = UiKey("address.learnMore") } object NewGroup { val Name = UiKey("newGroup.name") val Create = UiKey("newGroup.create") + val Incognito = UiKey("newGroup.incognito") + } + object NewChannel { + val Name = UiKey("newChannel.name") + val Create = UiKey("newChannel.create") } object Profile { val Name = UiKey("profile.name") + val FullName = UiKey("profile.fullName") + val Bio = UiKey("profile.bio") + val Description = UiKey("profile.description") + val DescriptionText = UiKey("profile.descriptionText") val Save = UiKey("profile.save") } object Profiles { @@ -135,6 +282,10 @@ object Ui { val Confirm = UiKey("hideProfile.confirm") val Save = UiKey("hideProfile.save") } + object ProfileAction { + val Password = UiKey("profileAction.password") + val Submit = UiKey("profileAction.submit") + } object NewProfile { val Name = UiKey("newProfile.name") val Create = UiKey("newProfile.create") @@ -143,27 +294,66 @@ object Ui { val Text = UiKey("link.text") val ShareProfile = UiKey("link.shareProfile") val ProfileIncognito = UiKey("link.profile.incognito") + fun Profile(userId: Long) = UiKey("link.profile", userId) + val ProfileBar = UiKey("link.profileBar") + val Paste = UiKey("link.paste") + val ShortLinkToggle = UiKey("link.shortLinkToggle") } object Chat { val Bar = UiKey("chat.bar") val Message = UiKey("chat.compose") - val Send = UiKey("chat.send") + val Send = Message.part("send") + val SendLive = Message.part("sendLive") + val Live = Message.part("live") + val ClearText = Message.part("clearText") + val SendTimed = Message.part("timed") + val ScopeMessage = UiKey("chat.compose", "scope") + val ScopeSend = ScopeMessage.part("send") val Connect = UiKey("chat.connect") val BarMenu = UiKey("chat.bar.menu") fun BarMenuItem(action: String) = UiKey("chat.bar.menu.$action") val Selection = UiKey("chat.selection") + val TextSelectionCopy = UiKey("chat.textSelection.copy") val RequestAccept = UiKey("chat.request.accept") val RequestReject = UiKey("chat.request.reject") + val PendingAccept = UiKey("chat.pending.accept") + val PendingReject = UiKey("chat.pending.reject") fun Item(itemId: Long) = UiKey("chat.item", itemId) fun ItemMenu(itemId: Long, action: String) = UiKey("chat.item.menu.$action", itemId) fun ItemFile(itemId: Long) = UiKey("chat.item.file", itemId) val ScrollToBottom = UiKey("chat.scrollToBottom") + val UnreadAbove = UiKey("chat.unreadAbove") + val SearchButton = UiKey("chat.searchButton") + val ContentFilter = UiKey("chat.contentFilter") fun ItemReaction(itemId: Long, emoji: String) = UiKey("chat.item.menu.react", "$itemId:$emoji") fun ReactionChip(itemId: Long, emoji: String) = UiKey("chat.item.reaction", "$itemId:$emoji") + fun ReactionMember(itemId: Long, name: String) = UiKey("chat.item.reaction.member", "$itemId:$name") + val FullScreenMedia = UiKey("chat.fullScreenMedia") fun MentionMember(groupMemberId: Long) = UiKey("chat.mentionMember", groupMemberId) + fun PickerUser(userId: Long) = UiKey("chat.picker.user", userId) + val PickerIncognito = UiKey("chat.picker.incognito") + val Reports = UiKey("chat.reports") + val SupportChats = UiKey("chat.supportChats") + fun ReportReason(label: String) = UiKey("chat.reportReason", label) + fun Attach(option: String) = UiKey("chat.attach", option) + val CommandsButton = UiKey("chat.commands") + fun Command(keyword: String) = UiKey("chat.commands.command", keyword) + fun CommandMenu(label: String) = UiKey("chat.commands.menu", label) + val CommandsBack = UiKey("chat.commands.back") + } + object TimePicker { + val Value = UiKey("timePicker.value") + val Confirm = UiKey("timePicker.confirm") + } + object ItemInfo { + fun Member(groupMemberId: Long) = UiKey("itemInfo.member", groupMemberId) + val Quote = UiKey("itemInfo.quote") + val ForwardedFrom = UiKey("itemInfo.forwardedFrom") + val Share = UiKey("itemInfo.share") } object Share { fun Row(chatId: String) = UiKey("share.row", chatId) + val Bar = UiKey("share.bar") } object DbError { val Passphrase = UiKey("dbError.passphrase") @@ -173,7 +363,32 @@ object Ui { object Info { val Alias = UiKey("info.alias") val DeleteContact = UiKey("info.deleteContact") + val ClearChat = UiKey("info.clearChat") val VerifyCode = UiKey("info.verifyCode") + val Preferences = UiKey("info.preferences") + val Name = UiKey("info.name") + val Header = UiKey("info.header") + val Mute = UiKey("info.mute") + val SwitchAddress = UiKey("info.switchAddress") + val AbortSwitchAddress = UiKey("info.abortSwitchAddress") + val ReceivingVia = UiKey("info.receivingVia") + val SendingVia = UiKey("info.sendingVia") + val Call = UiKey("info.call") + val SendReceipts = UiKey("info.sendReceipts") + val Ttl = UiKey("info.ttl") + } + object ContactPrefs { + fun Allow(feature: ChatFeature) = UiKey("contactPrefs.allow", feature.name) + val Timed = UiKey("contactPrefs.timed") + val Ttl = UiKey("contactPrefs.ttl") + val Reset = UiKey("contactPrefs.reset") + val Save = UiKey("contactPrefs.save") + } + object UserPrefs { + fun Allow(feature: ChatFeature) = UiKey("userPrefs.allow", feature.name) + val Timed = UiKey("userPrefs.timed") + val Reset = UiKey("userPrefs.reset") + val Save = UiKey("userPrefs.save") } object VerifyCode { val Code = UiKey("verifyCode.code") @@ -182,18 +397,57 @@ object Ui { object InviteMembers { fun Contact(contactId: Long) = UiKey("inviteMembers.contact", contactId) val Invite = UiKey("inviteMembers.invite") + val Role = UiKey("inviteMembers.role") + val Skip = UiKey("inviteMembers.skip") + val Search = UiKey("inviteMembers.search") + val Clear = UiKey("inviteMembers.clear") } object GroupInfo { fun Member(groupMemberId: Long) = UiKey("groupInfo.member", groupMemberId) val CreateLink = UiKey("groupInfo.createLink") + val Preferences = UiKey("groupInfo.preferences") + val MemberSupport = UiKey("groupInfo.memberSupport") + val UserSupport = UiKey("groupInfo.userSupport") + val AddMembers = UiKey("groupInfo.addMembers") + val EditProfile = UiKey("groupInfo.editProfile") + val WelcomeMessage = UiKey("groupInfo.welcomeMessage") + val Leave = UiKey("groupInfo.leave") + val Delete = UiKey("groupInfo.delete") + val Reports = UiKey("groupInfo.reports") + val Content = UiKey("groupInfo.content") + val Mute = UiKey("groupInfo.mute") + val SendReceipts = UiKey("groupInfo.sendReceipts") + val GroupLink = UiKey("groupInfo.groupLink") + val MembersToolbar = UiKey("groupInfo.membersToolbar") + fun MemberMenu(groupMemberId: Long, action: String) = UiKey("groupInfo.member.menu.$action", groupMemberId) + } + object GroupProfile { + val Name = UiKey("groupProfile.name") + val ShortDescr = UiKey("groupProfile.shortDescr") + val Save = UiKey("groupProfile.save") + } + object Welcome { + val Text = UiKey("welcome.text") + val Preview = UiKey("welcome.preview") + val Save = UiKey("welcome.save") } object GroupLink { val Share = UiKey("groupLink.share") + val Continue = UiKey("groupLink.continue") + val Role = UiKey("groupLink.role") } object MemberInfo { val Role = UiKey("memberInfo.role") val Remove = UiKey("memberInfo.remove") val BlockForAll = UiKey("memberInfo.blockForAll") + val UnblockForAll = UiKey("memberInfo.unblockForAll") + val Block = UiKey("memberInfo.block") + val Unblock = UiKey("memberInfo.unblock") + val Message = UiKey("memberInfo.message") + val Call = UiKey("memberInfo.call") + val Video = UiKey("memberInfo.video") + val DeleteMessages = UiKey("memberInfo.deleteMessages") + val SupportChat = UiKey("memberInfo.supportChat") } enum class AlertSource { Shared, PrivacySensitive } object Alert { @@ -201,4 +455,38 @@ object Ui { fun Dismiss(src: AlertSource) = UiKey("alert.dismiss", src) fun Option(src: AlertSource, name: String) = UiKey("alert.option.$name", src) } + object GroupPrefs { + val MemberAdmission = UiKey("groupPrefs.memberAdmission") + fun Toggle(feature: GroupFeature) = UiKey("groupPrefs.toggle", feature.name) + fun Role(feature: GroupFeature) = UiKey("groupPrefs.role", feature.name) + val Ttl = UiKey("groupPrefs.ttl") + val Reset = UiKey("groupPrefs.reset") + val Save = UiKey("groupPrefs.save") + } + object MemberAdmission { + val Review = UiKey("memberAdmission.review") + val Save = UiKey("memberAdmission.save") + } + object MemberSupport { + val Search = UiKey("memberSupport.search") + fun Row(groupMemberId: Long) = UiKey("memberSupport.row", groupMemberId) + fun RowMenu(groupMemberId: Long, action: String) = UiKey("memberSupport.row.menu.$action", groupMemberId) + } + object Database { + val Ttl = UiKey("database.ttl") + val Management = UiKey("database.management") + val DeleteFiles = UiKey("database.deleteFiles") + val Passphrase = UiKey("database.passphrase") + val Export = UiKey("database.export") + val Import = UiKey("database.import") + val Delete = UiKey("database.delete") + val RunChat = UiKey("database.runChat") + } + object DbPassphrase { + val Save = UiKey("dbPassphrase.save") + val Current = UiKey("dbPassphrase.current") + val New = UiKey("dbPassphrase.new") + val Confirm = UiKey("dbPassphrase.confirm") + val Update = UiKey("dbPassphrase.update") + } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 1bfe88ecf7..5245dcbd77 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -110,6 +111,7 @@ fun TerminalLayout( sendLiveMessage = null, updateLiveMessage = null, editPrevMessage = {}, + uiKey = Ui.Chat.Message, onMessageChange = ::onMessageChange, onFilesPasted = {}, textStyle = textStyle, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index b9cf956091..a8cf7e62ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -132,7 +132,7 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { val validName = mkValidName(name) Spacer(Modifier.height(20.dp)) if (name != validName) { - IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) { + IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp).ui(Ui.NewProfile.Name.part("invalid"))) { Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index cb7e1d63c1..cd52e49bd9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.* import chat.simplex.common.ui.ui import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey import chat.simplex.common.views.call.CallMediaType import chat.simplex.common.views.chatlist.* import androidx.compose.ui.text.* @@ -261,7 +262,7 @@ private fun deleteContactOrConversationDialog(chat: Chat, contact: Contact, chat if (chatModel.controller.appPrefs.showDeleteConversationNotice.get()) { showDeleteConversationNotice(contact) } - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "onlyConversation")) { Text(generalGetString(MR.strings.only_delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } // Delete contact @@ -274,7 +275,7 @@ private fun deleteContactOrConversationDialog(chat: Chat, contact: Contact, chat // Cancel SectionItemView({ AlertManager.shared.hideAlert() - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "cancel")) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } @@ -329,6 +330,7 @@ private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: C contactDeleteMode.value = if (it) ContactDeleteMode.Entity() else ContactDeleteMode.Full() }, + modifier = Modifier.ui(Ui.Alert.Option(Ui.AlertSource.Shared, "keepConversation")), ) } } @@ -339,7 +341,7 @@ private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: C if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) { showDeleteContactNotice(contact) } - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteWithoutNotification")) { Text(generalGetString(MR.strings.delete_without_notification), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } // Delete contact and notify @@ -379,7 +381,7 @@ private fun deleteContactWithoutConversation(chat: Chat, chatModel: ChatModel, c close, chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = true) ) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteAndNotify")) { Text( generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), @@ -396,7 +398,7 @@ private fun deleteContactWithoutConversation(chat: Chat, chatModel: ChatModel, c close, chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = false) ) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteWithoutNotification")) { Text( generalGetString(MR.strings.delete_without_notification), Modifier.fillMaxWidth(), @@ -408,7 +410,7 @@ private fun deleteContactWithoutConversation(chat: Chat, chatModel: ChatModel, c // Cancel SectionItemView({ AlertManager.shared.hideAlert() - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "cancel")) { Text( stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), @@ -435,7 +437,7 @@ private fun deleteNotReadyContact(chat: Chat, chatModel: ChatModel, close: (() - close, chatDeleteMode = ContactDeleteMode.Full().toChatDeleteMode(notify = false) ) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "confirm")) { Text( generalGetString(MR.strings.confirm_verb), Modifier.fillMaxWidth(), @@ -446,7 +448,7 @@ private fun deleteNotReadyContact(chat: Chat, chatModel: ChatModel, close: (() - // Cancel SectionItemView({ AlertManager.shared.hideAlert() - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "cancel")) { Text( stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), @@ -574,7 +576,7 @@ fun ChatInfoLayout( verticalAlignment = Alignment.CenterVertically ) { SearchButton(modifier = Modifier.fillMaxWidth(0.25f), chat, contact, close, onSearchClicked) - AudioCallButton(modifier = Modifier.fillMaxWidth(0.33f), chat, contact, connStats) + AudioCallButton(modifier = Modifier.fillMaxWidth(0.33f), chat, contact, connStats, uiKey = Ui.Info.Call) VideoButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, connStats) MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, contact) } @@ -620,7 +622,7 @@ fun ChatInfoLayout( SectionDividerSpaced() SectionView { - ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) + ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems, uiKey = Ui.Info.Ttl) } SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) SectionDividerSpaced() @@ -659,21 +661,23 @@ fun ChatInfoLayout( if (cStats != null) { SwitchAddressButton( disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null } || cStats.ratchetSyncSendProhibited, + uiKey = Ui.Info.SwitchAddress, switchAddress = switchContactAddress ) if (cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null }) { AbortSwitchAddressButton( disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null && !it.canAbortSwitch } || cStats.ratchetSyncSendProhibited, + uiKey = Ui.Info.AbortSwitchAddress, abortSwitchAddress = abortSwitchContactAddress ) } val rcvServers = cStats.rcvQueuesInfo.map { it.rcvServer } if (rcvServers.isNotEmpty()) { - SimplexServers(stringResource(MR.strings.receiving_via), rcvServers) + SimplexServers(stringResource(MR.strings.receiving_via), rcvServers, Ui.Info.ReceivingVia) } val sndServers = cStats.sndQueuesInfo.map { it.sndServer } if (sndServers.isNotEmpty()) { - SimplexServers(stringResource(MR.strings.sending_via), sndServers) + SimplexServers(stringResource(MR.strings.sending_via), sndServers, Ui.Info.SendingVia) } } } @@ -712,7 +716,7 @@ fun ChatInfoLayout( @Composable fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { Column( - Modifier.padding(horizontal = DEFAULT_PADDING), + Modifier.ui(Ui.Info.Header).padding(horizontal = DEFAULT_PADDING), horizontalAlignment = Alignment.CenterHorizontally ) { ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight) @@ -756,7 +760,7 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) { textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis, - modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName) + modifier = Modifier.ui(Ui.Info.Name).combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName) ) ChatInfoDescription(cInfo, displayName, copyNameToClipboard) ContactSimplexNameView(contact) @@ -920,6 +924,7 @@ fun MuteButton( title = stringResource(nextNtfMode.text(false)), disabled = disabled, disabledLook = disabled, + uiKey = Ui.Info.Mute, onClick = { toggleNotifications(chat.remoteHostId, chat.chatInfo, nextNtfMode, chatModel, enableNtfs) } @@ -931,7 +936,8 @@ fun AudioCallButton( modifier: Modifier, chat: Chat, contact: Contact, - connectionStats: MutableState + connectionStats: MutableState, + uiKey: UiKey? = null ) { CallButton( modifier = modifier, @@ -940,7 +946,8 @@ fun AudioCallButton( connectionStats, icon = painterResource(MR.images.ic_call), title = generalGetString(MR.strings.info_view_call_button), - mediaType = CallMediaType.Audio + mediaType = CallMediaType.Audio, + uiKey = uiKey ) } @@ -970,7 +977,8 @@ fun CallButton( connectionStats: MutableState, icon: Painter, title: String, - mediaType: CallMediaType + mediaType: CallMediaType, + uiKey: UiKey? = null ) { val canCall = contact.ready && contact.active && contact.mergedPreferences.calls.enabled.forUser && chatModel.activeCall.value == null val needToAllowCallsToContact = remember(chat.chatInfo) { @@ -987,6 +995,7 @@ fun CallButton( title = title, disabled = chatModel.activeCall.value != null, disabledLook = !canCall, + uiKey = uiKey, onClick = when { canCall -> { { @@ -1068,6 +1077,7 @@ fun InfoViewActionButton( title: String, disabled: Boolean, disabledLook: Boolean, + uiKey: UiKey? = null, onClick: () -> Unit ) { Box(modifier) { @@ -1079,6 +1089,7 @@ fun InfoViewActionButton( ) { IconButton( onClick = onClick, + modifier = Modifier.ui(uiKey), enabled = !disabled ) { Box( @@ -1157,18 +1168,18 @@ private fun ServerImage(subStatus: SubscriptionStatus) { } @Composable -fun SimplexServers(text: String, servers: List) { +fun SimplexServers(text: String, servers: List, uiKey: UiKey? = null) { val info = servers.joinToString(separator = ", ") { it.substringAfter("@") } val clipboardManager: ClipboardManager = LocalClipboardManager.current - InfoRowEllipsis(text, info) { + InfoRowEllipsis(text, info, uiKey) { clipboardManager.setText(AnnotatedString(servers.joinToString(separator = ","))) showToast(generalGetString(MR.strings.copied)) } } @Composable -fun SwitchAddressButton(disabled: Boolean, switchAddress: () -> Unit) { - SectionItemView(switchAddress, disabled = disabled) { +fun SwitchAddressButton(disabled: Boolean, uiKey: UiKey? = null, switchAddress: () -> Unit) { + SectionItemView(switchAddress, disabled = disabled, uiKey = uiKey) { Text( stringResource(MR.strings.switch_receiving_address), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary @@ -1177,8 +1188,8 @@ fun SwitchAddressButton(disabled: Boolean, switchAddress: () -> Unit) { } @Composable -fun AbortSwitchAddressButton(disabled: Boolean, abortSwitchAddress: () -> Unit) { - SectionItemView(abortSwitchAddress, disabled = disabled) { +fun AbortSwitchAddressButton(disabled: Boolean, uiKey: UiKey? = null, abortSwitchAddress: () -> Unit) { + SectionItemView(abortSwitchAddress, disabled = disabled, uiKey = uiKey) { Text( stringResource(MR.strings.abort_switch_receiving_address), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary @@ -1225,7 +1236,8 @@ private fun ContactPreferencesButton(onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_toggle_on), stringResource(MR.strings.contact_preferences), - click = onClick + click = onClick, + uiKey = Ui.Info.Preferences, ) } @@ -1240,6 +1252,8 @@ private fun SendReceiptsOption(currentUser: User, state: State, on state, icon = painterResource(MR.images.ic_double_check), enabled = remember { mutableStateOf(true) }, + uiKey = Ui.Info.SendReceipts, + optionKey = { it.bool ?: "default" }, onSelected = onSelected ) } @@ -1261,6 +1275,7 @@ fun ClearChatButton(onClick: () -> Unit) { click = onClick, textColor = WarningOrange, iconColor = WarningOrange, + uiKey = Ui.Info.ClearChat, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 0c7f1937f7..11b4412a3f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -26,6 +26,8 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.MemberProfileImage import chat.simplex.common.views.chat.item.* @@ -134,7 +136,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools Column { Box( - Modifier.clipChatItem().background(quoteColor).padding(bottom = 3.dp) + Modifier.ui(Ui.ItemInfo.Quote).clipChatItem().background(quoteColor).padding(bottom = 3.dp) .combinedClickable(onLongClick = { showMenu.value = true }, onClick = {}) .onRightClick { showMenu.value = true } ) { @@ -219,7 +221,8 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools ModalManager.end.closeModals() } }, - padding = PaddingValues(start = 17.dp, end = DEFAULT_PADDING) + padding = PaddingValues(start = 17.dp, end = DEFAULT_PADDING), + uiKey = Ui.ItemInfo.ForwardedFrom ) { ForwardedFromSender(forwardedFromItem) } @@ -375,7 +378,8 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools @Composable fun MemberDeliveryStatusView(member: GroupMember, status: GroupSndStatus, sentViaProxy: Boolean?) { SectionItemView( - padding = PaddingValues(horizontal = 0.dp) + padding = PaddingValues(horizontal = 0.dp), + uiKey = Ui.ItemInfo.Member(member.groupMemberId) ) { MemberProfileImage(size = 36.dp, member) Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) @@ -413,7 +417,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools Box(modifier, contentAlignment = Alignment.Center) { Icon( painterResource(icon), - contentDescription = null, + contentDescription = status.statusDescription, tint = statusColor ) } @@ -559,6 +563,12 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools } } +private val GroupSndStatus.statusDescription: String? get() = when (this) { + is GroupSndStatus.Sent -> generalGetString(MR.strings.icon_descr_sent_msg_status_sent) + is GroupSndStatus.Rcvd -> generalGetString(MR.strings.icon_descr_sent_msg_status_rcvd) + else -> statusInto?.first +} + private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List): List> { return memberDeliveryStatuses.mapNotNull { mds -> chatModel.getGroupMember(mds.groupMemberId)?.let { mem -> 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 7bcb80babe..9505bebd36 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 @@ -731,7 +731,7 @@ fun ChatView( ModalManager.end.closeModals() } ModalManager.end.showModalCloseable(endButtons = { - ShareButton { + ShareButton(uiKey = Ui.ItemInfo.Share) { clipboard.shareText(itemInfoShareText(chatModel, initialCiInfo.first, initialCiInfo.second, chatModel.controller.appPrefs.developerTools.get())) } }) { close -> @@ -1245,7 +1245,7 @@ fun BoxScope.ChatInfoToolbar( { showMenu.value = false showSearch.value = true - }, enabled = chatInfo.noteFolder.ready + }, Modifier.ui(Ui.Chat.SearchButton), enabled = chatInfo.noteFolder.ready ) { Icon( painterResource(MR.images.ic_search), @@ -1307,7 +1307,7 @@ fun BoxScope.ChatInfoToolbar( if (chatInfo.groupInfo.canAddMembers) { if (!chatInfo.incognito && !chatInfo.groupInfo.useRelays) { menuItems.add { - ItemAction(stringResource(MR.strings.icon_descr_add_members), painterResource(MR.images.ic_person_add_500), onClick = { + ItemAction(stringResource(MR.strings.icon_descr_add_members), painterResource(MR.images.ic_person_add_500), uiKey = Ui.Chat.BarMenuItem("addMembers"), onClick = { showMenu.value = false addMembers(chatInfo.groupInfo) }) @@ -1344,6 +1344,7 @@ fun BoxScope.ChatInfoToolbar( barButtons.add { IconButton( { showContentFilterMenu.value = true }, + Modifier.ui(Ui.Chat.ContentFilter), enabled = enabled ) { Icon( @@ -1363,6 +1364,7 @@ fun BoxScope.ChatInfoToolbar( ItemAction( stringResource(nextNtfMode.text(chatInfo.hasMentions)), painterResource(nextNtfMode.icon), + uiKey = Ui.Chat.BarMenuItem("ntf"), onClick = { showMenu.value = false // Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu @@ -1432,7 +1434,7 @@ fun BoxScope.ChatInfoToolbar( val oneHandUI = remember { appPrefs.oneHandUI.state } val chatBottomBar = remember { appPrefs.chatBottomBar.state } val searchTrailingContent: @Composable (() -> Unit)? = if (showContentFilterButton) {{ - IconButton({ showContentFilterMenu.value = true }) { + IconButton({ showContentFilterMenu.value = true }, Modifier.ui(Ui.Chat.ContentFilter)) { Icon( painterResource(if (contentFilter.value == null) MR.images.ic_photo_library else MR.images.ic_photo_library_filled), null, @@ -1491,6 +1493,7 @@ fun BoxScope.ChatInfoToolbar( stringResource(filter.label), painterResource(if (isSelected) filter.iconFilled else filter.icon), color = if (isSelected) MaterialTheme.colors.primary else Color.Unspecified, + uiKey = Ui.Chat.ContentFilter.option(filter.name), onClick = { showContentFilterMenu.value = false if (contentFilter.value == filter) return@ItemAction @@ -1511,6 +1514,7 @@ fun BoxScope.ChatInfoToolbar( ItemAction( stringResource(MR.strings.content_filter_all_messages), painterResource(MR.images.ic_forum), + uiKey = Ui.Chat.ContentFilter.option("all"), onClick = { showContentFilterMenu.value = false contentFilter.value = null @@ -1685,6 +1689,7 @@ private fun SupportChatsCountToolbar( ) { Row( Modifier + .ui(Ui.Chat.Reports) .fillMaxWidth() .weight(1F) .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) @@ -1710,6 +1715,7 @@ private fun SupportChatsCountToolbar( if (supportUnreadCount > 0) { Row( Modifier + .ui(Ui.Chat.SupportChats) .fillMaxWidth() .weight(1F) .height(AppBarHeight * fontSizeSqrtMultiplier + statusBarPadding) @@ -2704,7 +2710,7 @@ fun BoxScope.FloatingButtons( val showDropDown = remember { mutableStateOf(false) } TopEndFloatingButton( - Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent).align(Alignment.TopEnd), + Modifier.ui(Ui.Chat.UnreadAbove).padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent).align(Alignment.TopEnd), topUnreadCount, requestedTopScroll, animatedScrollingInProgress, @@ -2730,6 +2736,7 @@ fun BoxScope.FloatingButtons( ItemAction( generalGetString(MR.strings.mark_read), painterResource(MR.images.ic_check), + uiKey = Ui.Chat.UnreadAbove.part("markRead"), onClick = { markChatRead() showDropDown.value = false @@ -3013,7 +3020,7 @@ private fun DownloadFilesButton( if (user != null) { TextButton( contentPadding = contentPadding, - modifier = modifier, + modifier = modifier.ui(Ui.Alert.Option(Ui.AlertSource.Shared, "download")), onClick = { AlertManager.shared.hideAlert() @@ -3043,7 +3050,7 @@ private fun ForwardButton( forwardContent(forwardPlan.chatItemIds, chatInfo) AlertManager.shared.hideAlert() }, - modifier = modifier, + modifier = modifier.ui(Ui.Alert.Option(Ui.AlertSource.Shared, "forward")), contentPadding = contentPadding ) { Text(stringResource(MR.strings.forward_chat_item), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt index 5f7aec3dd6..196adeb0fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/CommandsMenuView.kt @@ -20,8 +20,10 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.ui.ui import chat.simplex.common.views.chat.group.* import chat.simplex.common.views.chat.item.sendCommandMsg import chat.simplex.common.views.helpers.commandMenuAnimSpec @@ -112,6 +114,7 @@ fun CommandsMenuView( fun MenuLabelRow(prev: Pair>) { Box( modifier = Modifier + .ui(Ui.Chat.CommandsBack) .fillMaxWidth() .height(COMMAND_MENU_ROW_SIZE) .clickable { @@ -148,6 +151,7 @@ fun CommandsMenuView( is ChatBotCommand.Command -> { Box( modifier = Modifier + .ui(Ui.Chat.Command(cmd.keyword)) .fillMaxWidth() .height(COMMAND_MENU_ROW_SIZE) .clickable { @@ -184,6 +188,7 @@ fun CommandsMenuView( is ChatBotCommand.Menu -> Box( modifier = Modifier + .ui(Ui.Chat.CommandMenu(cmd.label)) .fillMaxWidth() .height(COMMAND_MENU_ROW_SIZE) .clickable { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt index 015308fe22..c815148b6a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextGroupDirectInvitationActionsView.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.common.views.helpers.* import chat.simplex.res.* @@ -74,7 +76,7 @@ fun ComposeContextMemberContactActionsView( .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, ) { - var rejectButtonModifier = Modifier.fillMaxWidth().fillMaxHeight().weight(1F) + var rejectButtonModifier = Modifier.ui(Ui.Chat.RequestReject).fillMaxWidth().fillMaxHeight().weight(1F) rejectButtonModifier = if (inProgress.value) rejectButtonModifier else rejectButtonModifier.clickable { showRejectMemberContactRequestAlert(rhId, contact) } @@ -94,7 +96,7 @@ fun ComposeContextMemberContactActionsView( color = if (inProgress.value) MaterialTheme.colors.secondary else Color.Red ) } - var acceptButtonModifier = Modifier.fillMaxWidth().fillMaxHeight().weight(1F) + var acceptButtonModifier = Modifier.ui(Ui.Chat.RequestAccept).fillMaxWidth().fillMaxHeight().weight(1F) acceptButtonModifier = if (inProgress.value) acceptButtonModifier else acceptButtonModifier.clickable { acceptMemberContact(rhId, contact.contactId, inProgress = inProgress) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index 8cda1226fd..c48dffb150 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.views.chat.group.removeMember import chat.simplex.common.views.chat.group.removeMemberDialog import chat.simplex.common.views.helpers.* @@ -41,6 +43,7 @@ fun ComposeContextPendingMemberActionsView( ) { Column( Modifier + .ui(Ui.Chat.PendingReject) .fillMaxWidth() .fillMaxHeight() .weight(1F) @@ -55,6 +58,7 @@ fun ComposeContextPendingMemberActionsView( Column( Modifier + .ui(Ui.Chat.PendingAccept) .fillMaxWidth() .fillMaxHeight() .weight(1F) @@ -91,14 +95,14 @@ fun acceptMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c SectionItemView({ AlertManager.shared.hideAlert() acceptMember(rhId, groupInfo, member, GroupMemberRole.Member, close) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "acceptAsMember")) { Text(generalGetString(MR.strings.accept_pending_member_alert_confirmation_as_member), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } // Accept as observer SectionItemView({ AlertManager.shared.hideAlert() acceptMember(rhId, groupInfo, member, GroupMemberRole.Observer, close) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "acceptAsObserver")) { Text(generalGetString(MR.strings.accept_pending_member_alert_confirmation_as_observer), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } // Cancel diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt index 80940bf631..56f466549b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt @@ -17,7 +17,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.IncognitoOptionImage import chat.simplex.common.views.usersettings.IncognitoView @@ -124,6 +126,7 @@ fun ComposeContextProfilePickerView( Modifier .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) + .ui(Ui.Chat.PickerUser(user.userId)) .clickable(onClick = { if (!chat.chatInfo.profileChangeProhibited) { if (selectedUser.value.userId == user.userId) { @@ -167,6 +170,7 @@ fun ComposeContextProfilePickerView( Modifier .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) + .ui(Ui.Chat.PickerIncognito) .clickable(onClick = { if (!chat.chatInfo.profileChangeProhibited) { if (incognitoDefault) { 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 b80fe11088..d97e8abc9d 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 @@ -33,6 +33,7 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.badges.openBadgesView import chat.simplex.common.views.chat.group.hostFromRelayLink @@ -1297,6 +1298,7 @@ fun ComposeView( val commandsEnabled = chat.chatInfo.sendMsgEnabled && chat.chatInfo.menuCommands.isNotEmpty() IconButton( onClick = { showCommandsMenu.value = !showCommandsMenu.value }, + modifier = Modifier.ui(Ui.Chat.CommandsButton), enabled = commandsEnabled ) { Box( @@ -1528,6 +1530,7 @@ fun ComposeView( }, editPrevMessage = ::editPrevMessage, onFilesPasted = { composeState.onFilesAttached(it) }, + uiKey = if (chatsCtx.secondaryContextFilter == null) Ui.Chat.Message else Ui.Chat.ScopeMessage, onMessageChange = ::onMessageChange, textStyle = textStyle, focusRequester = focusRequester, @@ -1573,7 +1576,7 @@ fun ComposeView( icon: ImageResource, connect: () -> Unit ) { - var modifier = Modifier.height(57.dp).fillMaxWidth() + var modifier = Modifier.height(57.dp).fillMaxWidth().ui(Ui.Chat.Connect) modifier = if (composeState.value.inProgress) modifier else modifier.clickable(onClick = { connect() }) Box( modifier, @@ -1881,13 +1884,13 @@ private fun showLinkPreviewsConfirmAlert(socksEnabled: Boolean, onChoice: (Boole SectionItemView({ AlertManager.shared.hideAlert() onChoice(false) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "disable")) { Text(stringResource(MR.strings.link_previews_alert_disable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ AlertManager.shared.hideAlert() onChoice(true) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "enable")) { Text(stringResource(MR.strings.link_previews_alert_enable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = if (socksEnabled) MaterialTheme.colors.primary else Color.Red) } // SectionItemView({ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index b7adec1add..4f085d46be 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.graphics.Color import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggle @@ -147,6 +148,8 @@ private fun FeatureSection( ContactFeatureAllowed.values(userDefault).map { it to it.text }, allowFeature, icon = null, + uiKey = Ui.ContactPrefs.Allow(feature), + optionKey = { if (it is ContactFeatureAllowed.UserDefault) "default" else it.allowed.name }, onSelected = onSelected ) InfoRow( @@ -180,6 +183,7 @@ private fun TimedMessagesFeatureSection( PreferenceToggle( generalGetString(MR.strings.chat_preferences_you_allow), checked = allowFeature.value, + uiKey = Ui.ContactPrefs.Timed, ) { allow -> onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null) } @@ -196,6 +200,7 @@ private fun TimedMessagesFeatureSection( dropdownValues = TimedMessagesPreference.ttlValues, customPickerTitle = generalGetString(MR.strings.delete_after), customPickerConfirmButtonText = generalGetString(MR.strings.custom_time_picker_select), + uiKey = Ui.ContactPrefs.Ttl, onSelected = onTTLUpdated ) } else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) { @@ -208,10 +213,10 @@ private fun TimedMessagesFeatureSection( @Composable private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) { SectionView { - SectionItemView(reset, disabled = disabled) { + SectionItemView(reset, disabled = disabled, uiKey = Ui.ContactPrefs.Reset) { Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } - SectionItemView(save, disabled = disabled) { + SectionItemView(save, disabled = disabled, uiKey = Ui.ContactPrefs.Save) { Text(stringResource(MR.strings.save_and_notify_contact), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 5e252dae3a..43aaaa21af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -16,6 +16,7 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.BackHandler import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* import dev.icerock.moko.resources.compose.stringResource import chat.simplex.res.* @@ -52,7 +53,7 @@ fun BoxScope.SelectedItemsCounterToolbar(selectedItems: MutableState?> @Composable private fun SelectAllButton(onClick: () -> Unit) { - IconButton(onClick) { + IconButton(onClick, Modifier.ui(Ui.Chat.Selection.part("selectAll"))) { Icon( painterResource(MR.images.ic_checklist), stringResource(MR.strings.back), Modifier.height(24.dp), tint = MaterialTheme.colors.primary ) @@ -94,7 +95,7 @@ fun SelectedItemsButtonsToolbar( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - IconButton({ if (canArchiveReports.value) archiveItems() else deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !deleteCountProhibited.value) { + IconButton({ if (canArchiveReports.value) archiveItems() else deleteItems(deleteForEveryoneEnabled.value) }, Modifier.ui(Ui.Chat.Selection.part("delete")), enabled = deleteEnabled.value && !deleteCountProhibited.value) { Icon( painterResource(MR.images.ic_delete), null, @@ -103,7 +104,7 @@ fun SelectedItemsButtonsToolbar( ) } - IconButton({ moderateItems() }, Modifier.alpha(if (canModerate.value) 1f else 0f), enabled = moderateEnabled.value && !deleteCountProhibited.value && chatsCtx.secondaryContextFilter == null) { + IconButton({ moderateItems() }, Modifier.ui(Ui.Chat.Selection.part("moderate")).alpha(if (canModerate.value) 1f else 0f), enabled = moderateEnabled.value && !deleteCountProhibited.value && chatsCtx.secondaryContextFilter == null) { Icon( painterResource(MR.images.ic_flag), null, @@ -112,7 +113,7 @@ fun SelectedItemsButtonsToolbar( ) } - IconButton({ forwardItems() }, enabled = forwardEnabled.value && !forwardCountProhibited.value && chatsCtx.secondaryContextFilter == null) { + IconButton({ forwardItems() }, Modifier.ui(Ui.Chat.Selection.part("forward")), enabled = forwardEnabled.value && !forwardCountProhibited.value && chatsCtx.secondaryContextFilter == null) { Icon( painterResource(MR.images.ic_forward), null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index eddb5d727d..b4b8a1810d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.ui import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* @@ -62,6 +62,7 @@ fun SendMsgView( cancelLiveMessage: (() -> Unit)? = null, editPrevMessage: () -> Unit, onFilesPasted: (List) -> Unit, + uiKey: UiKey? = null, onMessageChange: (ComposeMessage) -> Unit, textStyle: MutableState, focusRequester: FocusRequester? = null, @@ -90,7 +91,8 @@ fun SendMsgView( onMessageChange, editPrevMessage, onFilesPasted, - focusRequester + focusRequester, + uiKey ) { if (!cs.inProgress) { if (sendToConnect != null) { @@ -120,7 +122,7 @@ fun SendMsgView( } } if (showDeleteTextButton.value) { - DeleteTextButton(composeState) + DeleteTextButton(composeState, uiKey?.part("clearText")) } Box(Modifier.align(Alignment.BottomEnd).padding(bottom = if (appPlatform.isAndroid) 0.dp else 5.sp.toDp() * fontSizeSqrtMultiplier)) { val sendButtonSize = remember { Animatable(36f) } @@ -135,7 +137,7 @@ fun SendMsgView( when { cs.progressByTimeout -> ComposeProgressIndicator() cs.contextItem is ComposeContextItem.ReportedItem -> { - SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendToConnect, sendMessage) + SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendToConnect, sendMessage, uiKey?.part("send")) } showVoiceButton && sendMsgEnabled -> { Row(verticalAlignment = Alignment.CenterVertically) { @@ -161,7 +163,7 @@ fun SendMsgView( && cs.contextItem is ComposeContextItem.NoContextItem ) { Spacer(Modifier.width(12.dp)) - StartLiveMessageButton { + StartLiveMessageButton(uiKey?.part("live")) { if (composeState.value.preview is ComposePreview.NoPreview) { startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) } @@ -196,7 +198,8 @@ fun SendMsgView( onClick = { startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown) showDropdown.value = false - } + }, + uiKey = uiKey?.part("sendLive") ) } } @@ -205,6 +208,7 @@ fun SendMsgView( ItemAction( generalGetString(MR.strings.disappearing_message), painterResource(MR.images.ic_timer), + uiKey = uiKey?.part("timed"), onClick = { showCustomDisappearingMessageDialog.value = true showDropdown.value = false @@ -245,17 +249,18 @@ fun SendMsgView( if (!hideSendButton) { val menuItems = MenuItems() if (menuItems.isNotEmpty()) { - SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendToConnect, sendMessage) { showDropdown.value = true } + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendToConnect, sendMessage, uiKey?.part("send")) { showDropdown.value = true } DefaultDropdownMenu(showDropdown) { menuItems.forEach { composable -> composable() } } CustomDisappearingMessageDialog( showCustomDisappearingMessageDialog, + uiKey = uiKey?.part("timed"), sendMessage = sendMessage, customDisappearingMessageTimePref = customDisappearingMessageTimePref ) } else { - SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendToConnect, sendMessage) + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendToConnect, sendMessage, uiKey?.part("send")) } } } @@ -273,6 +278,7 @@ expect fun VoiceButtonWithoutPermissionByPlatform() @Composable private fun CustomDisappearingMessageDialog( showMenu: MutableState, + uiKey: UiKey?, sendMessage: (Int?) -> Unit, customDisappearingMessageTimePref: SharedPreference? ) { @@ -284,7 +290,7 @@ private fun CustomDisappearingMessageDialog( color = MaterialTheme.colors.secondary ) - ItemAction(generalGetString(MR.strings.send_disappearing_message_30_seconds)) { + ItemAction(generalGetString(MR.strings.send_disappearing_message_30_seconds), uiKey = uiKey?.option(30)) { sendMessage(30) showMenu.value = false } @@ -292,11 +298,11 @@ private fun CustomDisappearingMessageDialog( sendMessage(60) showMenu.value = false } - ItemAction(generalGetString(MR.strings.send_disappearing_message_5_minutes)) { + ItemAction(generalGetString(MR.strings.send_disappearing_message_5_minutes), uiKey = uiKey?.option(300)) { sendMessage(300) showMenu.value = false } - ItemAction(generalGetString(MR.strings.send_disappearing_message_custom_time)) { + ItemAction(generalGetString(MR.strings.send_disappearing_message_custom_time), uiKey = uiKey?.option("custom")) { showMenu.value = false val selectedDisappearingMessageTime = mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300) showCustomTimePickerDialog( @@ -314,10 +320,10 @@ private fun CustomDisappearingMessageDialog( } @Composable -private fun BoxScope.DeleteTextButton(composeState: MutableState) { +private fun BoxScope.DeleteTextButton(composeState: MutableState, uiKey: UiKey?) { IconButton( { composeState.value = composeState.value.copy(message = ComposeMessage()) }, - Modifier.align(Alignment.TopEnd).size(36.dp) + Modifier.ui(uiKey).align(Alignment.TopEnd).size(36.dp) ) { Icon(painterResource(MR.images.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary) } @@ -459,12 +465,13 @@ private fun SendMsgButton( enabled: Boolean, sendToConnect: (() -> Unit)?, sendMessage: (Int?) -> Unit, + uiKey: UiKey?, onLongClick: (() -> Unit)? = null ) { val interactionSource = remember { MutableInteractionSource() } val ripple = remember { ripple(bounded = false, radius = 24.dp) } Box( - modifier = Modifier.ui(Ui.Chat.Send).requiredSize(36.dp) + modifier = Modifier.ui(uiKey).requiredSize(36.dp) .combinedClickable( onClick = { if (sendToConnect != null) { @@ -498,11 +505,11 @@ private fun SendMsgButton( } @Composable -private fun StartLiveMessageButton(onClick: () -> Unit) { +private fun StartLiveMessageButton(uiKey: UiKey?, onClick: () -> Unit) { val interactionSource = remember { MutableInteractionSource() } val ripple = remember { ripple(bounded = false, radius = 24.dp) } Box( - modifier = Modifier.requiredSize(36.dp) + modifier = Modifier.ui(uiKey).requiredSize(36.dp) .clickable( onClick = onClick, role = Role.Button, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index e04f75d799..dd0d7c7fed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -36,6 +36,8 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.views.chat.item.itemPrefixText import chat.simplex.common.views.chat.item.itemDisplay import chat.simplex.common.views.helpers.generalGetString @@ -558,6 +560,7 @@ fun SelectionCopyButton() { .background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp)) .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp)) + .ui(Ui.Chat.TextSelectionCopy) .clickable { manager.onCopySelection?.invoke() manager.clearSelection() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 86380f9580..fb6c602d66 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -31,6 +31,7 @@ import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -215,7 +216,7 @@ private fun SearchRowView( Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.secondary) } Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) - SearchTextField(Modifier.fillMaxWidth(), searchText = searchText, alwaysVisible = true) { + SearchTextField(Modifier.fillMaxWidth().ui(Ui.InviteMembers.Search), searchText = searchText, alwaysVisible = true) { searchText.value = searchText.value.copy(it) } } @@ -235,7 +236,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState Unit) { click = onClick, textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary, + uiKey = Ui.InviteMembers.Skip, ) } @@ -279,7 +282,7 @@ fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelec fontSize = 14.sp ) Box( - Modifier.clickable { if (enabled) clearSelection() } + Modifier.ui(Ui.InviteMembers.Clear).clickable { if (enabled) clearSelection() } ) { Text( stringResource(MR.strings.clear_contacts_selection_button), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 6270bd6d23..5711bdd599 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -40,6 +40,8 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* @@ -373,7 +375,7 @@ private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: Lis SectionItemView({ AlertManager.shared.hideAlert() removeMembers(rhId, groupInfo, memberIds, withMessages = false, onSuccess = onSuccess) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "remove")) { Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) } SectionItemView({ @@ -431,6 +433,7 @@ fun MuteButton( title = generalGetString(nextNotificationMode.text(true)), disabled = !groupInfo.ready, disabledLook = !groupInfo.ready, + uiKey = Ui.GroupInfo.Mute, onClick = { toggleNotifications(chat.remoteHostId, chat.chatInfo, nextNotificationMode, chatModel, notificationMode) } @@ -499,6 +502,7 @@ fun UserSupportChatButton( } }, iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary), + uiKey = Ui.GroupInfo.UserSupport, ) { if (chat.supportUnreadCount > 0) { UnreadBadge( @@ -559,6 +563,7 @@ fun ModalData.GroupChatInfoLayout( val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val imePadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() LazyColumnWithScrollBar( + Modifier.ui(Ui.GroupInfo.Content), state = listState, contentPadding = if (oneHandUI.value) { PaddingValues( @@ -945,11 +950,12 @@ private fun SelectedItemsCounterToolbarSetter( } @Composable -fun ChatTTLOption(chatItemTTL: State, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State) { +fun ChatTTLOption(chatItemTTL: State, setChatItemTTL: (ChatItemTTL?) -> Unit, deletingItems: State, uiKey: UiKey? = null) { Box { TtlOptions( chatItemTTL, enabled = remember { derivedStateOf { !deletingItems.value } }, + uiKey = uiKey, onSelected = setChatItemTTL, default = chatModel.chatItemTTL ) @@ -1018,7 +1024,8 @@ private fun MemberSupportButton(chat: Chat, onClick: () -> Unit) { painterResource(if (chat.supportUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.member_support), click = onClick, - iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) + iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary), + uiKey = Ui.GroupInfo.MemberSupport ) { if (chat.supportUnreadCount > 0) { UnreadBadge( @@ -1034,7 +1041,8 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit) SettingsActionItem( painterResource(MR.images.ic_toggle_on), stringResource(titleId), - click = onClick + click = onClick, + uiKey = Ui.GroupInfo.Preferences ) } @@ -1044,7 +1052,8 @@ private fun GroupReportsButton(chat: Chat, onClick: () -> Unit) { painterResource(if (chat.chatStats.reportsCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.group_reports_member_reports), click = onClick, - iconColor = (if (chat.chatStats.reportsCount > 0) Color.Red else MaterialTheme.colors.secondary) + iconColor = (if (chat.chatStats.reportsCount > 0) Color.Red else MaterialTheme.colors.secondary), + uiKey = Ui.GroupInfo.Reports ) { if (chat.chatStats.reportsCount > 0) { UnreadBadge( @@ -1066,6 +1075,8 @@ private fun SendReceiptsOption(currentUser: User, state: State, on state, icon = painterResource(MR.images.ic_double_check), enabled = remember { mutableStateOf(true) }, + uiKey = Ui.GroupInfo.SendReceipts, + optionKey = { it.bool }, onSelected = onSelected ) } @@ -1093,7 +1104,8 @@ private fun AddMembersButton(titleId: StringResource, tint: Color = MaterialThem stringResource(titleId), onClick, iconColor = tint, - textColor = tint + textColor = tint, + uiKey = Ui.GroupInfo.AddMembers ) } @@ -1191,38 +1203,38 @@ private fun DropDownMenuForMember(rhId: Long?, member: GroupMember, groupInfo: G DefaultDropdownMenu(showMenu) { if (canBlockForAll) { if (member.blockedByAdmin) { - ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), onClick = { + ItemAction(stringResource(MR.strings.unblock_for_all), painterResource(MR.images.ic_do_not_touch), uiKey = Ui.GroupInfo.MemberMenu(member.groupMemberId, "unblockForAll"), onClick = { unblockForAllAlert(rhId, groupInfo, member) showMenu.value = false }) } else { - ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { + ItemAction(stringResource(MR.strings.block_for_all), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, uiKey = Ui.GroupInfo.MemberMenu(member.groupMemberId, "blockForAll"), onClick = { blockForAllAlert(rhId, groupInfo, member) showMenu.value = false }) } } if (canRemove) { - ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, uiKey = Ui.GroupInfo.MemberMenu(member.groupMemberId, "remove"), onClick = { removeMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } if (selectedItems.value == null && member.memberRole < GroupMemberRole.Moderator) { Divider() - SelectItemAction(showMenu) { toggleItemSelection(member.groupMemberId, selectedItems) } + SelectItemAction(showMenu, Ui.GroupInfo.MemberMenu(member.groupMemberId, "select")) { toggleItemSelection(member.groupMemberId, selectedItems) } } } } } else if (!member.blockedByAdmin) { DefaultDropdownMenu(showMenu) { if (member.memberSettings.showMessages) { - ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { + ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, uiKey = Ui.GroupInfo.MemberMenu(member.groupMemberId, "block"), onClick = { blockMemberAlert(rhId, groupInfo, member) showMenu.value = false }) } else { - ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { + ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), uiKey = Ui.GroupInfo.MemberMenu(member.groupMemberId, "unblock"), onClick = { unblockMemberAlert(rhId, groupInfo, member) showMenu.value = false }) @@ -1237,7 +1249,8 @@ private fun GroupLinkButton(onClick: () -> Unit) { painterResource(MR.images.ic_link), stringResource(MR.strings.group_link), onClick, - iconColor = MaterialTheme.colors.secondary + iconColor = MaterialTheme.colors.secondary, + uiKey = Ui.GroupInfo.GroupLink ) } @@ -1341,7 +1354,8 @@ fun EditGroupProfileButton(titleId: StringResource = MR.strings.button_edit_grou painterResource(MR.images.ic_edit), stringResource(titleId), onClick, - iconColor = MaterialTheme.colors.secondary + iconColor = MaterialTheme.colors.secondary, + uiKey = Ui.GroupInfo.EditProfile ) } @@ -1356,7 +1370,8 @@ private fun AddOrEditWelcomeMessage(welcomeMessage: String?, onClick: () -> Unit painterResource(MR.images.ic_maps_ugc), text, onClick, - iconColor = MaterialTheme.colors.secondary + iconColor = MaterialTheme.colors.secondary, + uiKey = Ui.GroupInfo.WelcomeMessage ) } @@ -1367,7 +1382,8 @@ private fun LeaveGroupButton(titleId: StringResource, onClick: () -> Unit) { stringResource(titleId), onClick, iconColor = Color.Red, - textColor = Color.Red + textColor = Color.Red, + uiKey = Ui.GroupInfo.Leave ) } @@ -1378,19 +1394,21 @@ private fun DeleteGroupButton(titleId: StringResource, onClick: () -> Unit) { stringResource(titleId), onClick, iconColor = Color.Red, - textColor = Color.Red + textColor = Color.Red, + uiKey = Ui.GroupInfo.Delete ) } @Composable fun MemberListSearchRowView( - searchText: MutableState = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + searchText: MutableState = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }, + uiKey: UiKey? = null ) { Box(Modifier.width(36.dp), contentAlignment = Alignment.Center) { Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.secondary) } Spacer(Modifier.width(14.dp)) - SearchTextField(Modifier.fillMaxWidth(), searchText = searchText, alwaysVisible = true) { + SearchTextField(Modifier.fillMaxWidth().ui(uiKey), searchText = searchText, alwaysVisible = true) { searchText.value = searchText.value.copy(it) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 86d74900be..8fece09560 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -294,6 +294,7 @@ fun GroupLinkLayout( click = close, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, + uiKey = Ui.GroupLink.Continue, ) } } @@ -316,7 +317,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState Unit) { click = onClick, textColor = Color.Red, iconColor = Color.Red, + uiKey = Ui.MemberInfo.Block, ) } @@ -816,7 +821,8 @@ fun UnblockMemberButton(onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_do_not_touch), stringResource(MR.strings.unblock_member_button), - click = onClick + click = onClick, + uiKey = Ui.MemberInfo.Unblock, ) } @@ -837,7 +843,8 @@ fun UnblockForAllButton(onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_do_not_touch), stringResource(MR.strings.unblock_for_all), - click = onClick + click = onClick, + uiKey = Ui.MemberInfo.UnblockForAll, ) } @@ -864,6 +871,7 @@ fun DeleteMemberMessagesButton(onClick: () -> Unit) { click = onClick, textColor = Color.Red, iconColor = Color.Red, + uiKey = Ui.MemberInfo.DeleteMessages, ) } @@ -882,6 +890,7 @@ fun ShareRelayAddressButton(onClick: () -> Unit) { fun OpenChatButton( modifier: Modifier, disabledLook: Boolean = false, + uiKey: UiKey? = null, onClick: () -> Unit ) { InfoViewActionButton( @@ -890,6 +899,7 @@ fun OpenChatButton( title = generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = disabledLook, + uiKey = uiKey, onClick = onClick ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt index 6dcc6e9a4f..1a16157da4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMembersToolbar.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.unit.max import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* import chat.simplex.res.* @@ -59,7 +61,7 @@ fun SelectedItemsMembersToolbar( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - IconButton(delete, enabled = deleteEnabled.value) { + IconButton(delete, Modifier.ui(Ui.GroupInfo.MembersToolbar.part("remove")), enabled = deleteEnabled.value) { Icon( painterResource(MR.images.ic_delete), null, @@ -68,7 +70,7 @@ fun SelectedItemsMembersToolbar( ) } - IconButton({ blockForAll(blockForAllEnabled.value) }, enabled = blockForAllButtonEnabled.value) { + IconButton({ blockForAll(blockForAllEnabled.value) }, Modifier.ui(Ui.GroupInfo.MembersToolbar.part("block")), enabled = blockForAllButtonEnabled.value) { Icon( painterResource(if (unblockForAllEnabled.value && blockForAllButtonEnabled.value) MR.images.ic_do_not_touch else MR.images.ic_back_hand), null, @@ -77,7 +79,7 @@ fun SelectedItemsMembersToolbar( ) } - IconButton({ changeRole(if (roleToMemberEnabled.value) GroupMemberRole.Member else GroupMemberRole.Observer) }, enabled = roleButtonEnabled.value) { + IconButton({ changeRole(if (roleToMemberEnabled.value) GroupMemberRole.Member else GroupMemberRole.Observer) }, Modifier.ui(Ui.GroupInfo.MembersToolbar.part("role")), enabled = roleButtonEnabled.value) { Icon( painterResource(if (roleToObserverEnabled.value || !roleButtonEnabled.value) MR.images.ic_person else MR.images.ic_person_edit), null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index bd3af10c37..ecc2807af8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.runtime.saveable.rememberSaveable import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon @@ -258,7 +259,8 @@ private fun MemberAdmissionButton(onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_toggle_on), stringResource(MR.strings.member_admission), - click = onClick + click = onClick, + uiKey = Ui.GroupPrefs.MemberAdmission ) } @@ -286,6 +288,7 @@ private fun FeatureSection( iconTint, disabled = disabled, checked = enableFeature.value == GroupFeatureEnabled.ON, + uiKey = Ui.GroupPrefs.Toggle(feature), ) { checked -> onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF, enableForRole?.value) } @@ -298,6 +301,7 @@ private fun FeatureSection( dropdownValues = TimedMessagesPreference.ttlValues, customPickerTitle = generalGetString(MR.strings.delete_after), customPickerConfirmButtonText = generalGetString(MR.strings.custom_time_picker_select), + uiKey = Ui.GroupPrefs.Ttl, onSelected = onTTLUpdated ) } @@ -306,6 +310,7 @@ private fun FeatureSection( generalGetString(MR.strings.feature_enabled_for), featureRoles, enableForRole, + uiKey = Ui.GroupPrefs.Role(feature), onSelected = { value -> onSelected(enableFeature.value, value) } @@ -340,10 +345,10 @@ private fun FeatureSection( @Composable private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean, saveTextId: StringResource) { SectionView { - SectionItemView(reset, disabled = disabled) { + SectionItemView(reset, disabled = disabled, uiKey = Ui.GroupPrefs.Reset) { Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } - SectionItemView(save, disabled = disabled) { + SectionItemView(save, disabled = disabled, uiKey = Ui.GroupPrefs.Save) { Text(stringResource(saveTextId), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index c93179edd7..78d2e8908c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -18,7 +18,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.ui import chat.simplex.common.views.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.ReadableText @@ -139,7 +141,7 @@ fun GroupProfileLayout( } } } - ProfileNameField(displayName, "", { isValidNewProfileName(it, groupProfile) }, focusRequester) + ProfileNameField(displayName, "", { isValidNewProfileName(it, groupProfile) }, focusRequester, uiKey = Ui.GroupProfile.Name) if (groupProfile.fullName.trim().isNotEmpty() && groupProfile.fullName.trim() != groupProfile.displayName.trim()) { Spacer(Modifier.height(DEFAULT_PADDING)) Text( @@ -167,7 +169,7 @@ fun GroupProfileLayout( } } } - ProfileNameField(shortDescr, "", isValid = { bioFitsLimit(it) }) + ProfileNameField(shortDescr, "", isValid = { bioFitsLimit(it) }, uiKey = Ui.GroupProfile.ShortDescr) Spacer(Modifier.height(DEFAULT_PADDING)) val enabled = !dataUnchanged && canUpdateProfile(displayName.value, shortDescr.value, groupProfile) @@ -175,7 +177,7 @@ fun GroupProfileLayout( if (enabled) { Text( stringResource(saveProfileLabel), - modifier = Modifier.clickable { + modifier = Modifier.ui(Ui.GroupProfile.Save).clickable { saveProfile( groupProfile.copy( displayName = displayName.value.trim(), @@ -190,6 +192,7 @@ fun GroupProfileLayout( } else { Text( stringResource(saveProfileLabel), + Modifier.ui(Ui.GroupProfile.Save), color = MaterialTheme.colors.secondary ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt index ef333a8826..303e8dcba4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt @@ -9,6 +9,7 @@ import SectionView import androidx.compose.foundation.background import androidx.compose.material.MaterialTheme import androidx.compose.ui.Modifier +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import androidx.compose.material.Text import androidx.compose.runtime.* @@ -119,6 +120,7 @@ private fun AdmissionSection( generalGetString(admissionStageStrId), memberCriterias, memberCriteria, + uiKey = Ui.MemberAdmission.Review, onSelected = { value -> onSelected(value) } @@ -139,7 +141,7 @@ private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Bool SectionItemView(reset, disabled = disabled) { Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } - SectionItemView(save, disabled = disabled) { + SectionItemView(save, disabled = disabled, uiKey = Ui.MemberAdmission.Save) { Text(stringResource(MR.strings.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index b05d425c46..9688f4a09a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -22,6 +22,7 @@ import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction @@ -125,7 +126,7 @@ private fun ModalData.MemberSupportViewLayout( } else { item { SectionItemView(padding = PaddingValues(start = 14.dp, end = DEFAULT_PADDING_HALF)) { - MemberListSearchRowView(searchText) + MemberListSearchRowView(searchText, uiKey = Ui.MemberSupport.Search) } } items(filteredmembersWithChats.value, key = { it.groupMemberId }) { member -> @@ -146,7 +147,8 @@ private fun ModalData.MemberSupportViewLayout( }, longClick = { showMenu.value = true }, minHeight = 54.dp, - padding = PaddingValues(horizontal = DEFAULT_PADDING) + padding = PaddingValues(horizontal = DEFAULT_PADDING), + uiKey = Ui.MemberSupport.Row(member.groupMemberId) ) { Box(contentAlignment = Alignment.CenterStart) { DropDownMenuForSupportChat(chat.remoteHostId, member, groupInfo, showMenu) @@ -270,18 +272,18 @@ fun SupportChatRow(member: GroupMember, isChannel: Boolean) { private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { DefaultDropdownMenu(showMenu) { if (member.memberPending) { - ItemAction(stringResource(MR.strings.accept_pending_member_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, onClick = { + ItemAction(stringResource(MR.strings.accept_pending_member_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, uiKey = Ui.MemberSupport.RowMenu(member.groupMemberId, "accept"), onClick = { acceptMemberDialog(rhId, groupInfo, member) showMenu.value = false }) } else { if (member.supportChatNotRead) { - ItemAction(stringResource(MR.strings.mark_read), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, onClick = { + ItemAction(stringResource(MR.strings.mark_read), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, uiKey = Ui.MemberSupport.RowMenu(member.groupMemberId, "markRead"), onClick = { markSupportChatRead(rhId, groupInfo, member) showMenu.value = false }) } - ItemAction(stringResource(MR.strings.delete_member_support_chat_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + ItemAction(stringResource(MR.strings.delete_member_support_chat_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, uiKey = Ui.MemberSupport.RowMenu(member.groupMemberId, "delete"), onClick = { deleteMemberSupportChatDialog(rhId, groupInfo, member) showMenu.value = false }) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index ef847c93cb..a81355b828 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -22,7 +22,9 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.ui import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel @@ -99,7 +101,7 @@ private fun GroupWelcomeLayout( val focusRequester = remember { FocusRequester() } TextEditor( wt, - Modifier.height(140.dp), stringResource(MR.strings.enter_welcome_message), + Modifier.height(140.dp).ui(Ui.Welcome.Text), stringResource(MR.strings.enter_welcome_message), focusRequester = focusRequester ) LaunchedEffect(Unit) { @@ -144,7 +146,7 @@ private fun GroupWelcomeLayout( @Composable private fun TextPreview(text: String, linkMode: SimplexLinkMode, markdown: Boolean = true) { val uriHandler = LocalUriHandler.current - Column { + Column(Modifier.ui(Ui.Welcome.Preview)) { SelectionContainer(Modifier.fillMaxWidth()) { val parsed = if (markdown) remember(text) { parseToMarkdownText(text) } else null MarkdownText( @@ -164,7 +166,7 @@ private fun TextPreview(text: String, linkMode: SimplexLinkMode, markdown: Boole @Composable private fun SaveButton(save: () -> Unit, disabled: Boolean) { SectionView { - SectionItemView(save, disabled = disabled) { + SectionItemView(save, disabled = disabled, uiKey = Ui.Welcome.Save) { Text(stringResource(MR.strings.save_and_update_group_profile), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 14ecd723b9..f5ca909a17 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -16,7 +16,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.res.* @@ -86,10 +88,10 @@ fun CIGroupInvitationView( val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage Surface( - modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { + modifier = (if (action && !inProgress.value) Modifier.clickable(onClick = { inProgress.value = true joinGroup(groupInvitation.groupId) { inProgress.value = false } - }) else Modifier, + }) else Modifier).ui(Ui.Chat.Item(ci.id).part("join")), shape = RoundedCornerShape(18.dp), color = if (sent) sentColor else receivedColor, contentColor = LocalContentColor.current diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index a49884beeb..785d057d92 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -24,7 +24,9 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH +import chat.simplex.common.ui.ui import chat.simplex.common.views.chat.chatViewScrollState import chat.simplex.res.* import dev.icerock.moko.resources.StringResource @@ -38,6 +40,7 @@ fun CIImageView( imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, smallView: Boolean, + uiKey: UiKey? = null, receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } @@ -101,13 +104,14 @@ fun CIImageView( } @Composable - fun imageView(imageBitmap: ImageBitmap, onClick: () -> Unit) { + fun imageView(imageBitmap: ImageBitmap, key: UiKey? = null, onClick: () -> Unit) { Image( imageBitmap, contentDescription = stringResource(MR.strings.image_descr), // .width(DEFAULT_MAX_IMAGE_WIDTH) is a hack for image to increase IntrinsicSize of FramedItemView // if text is short and take all available width if text is long modifier = Modifier + .ui(key) .width(if (imageBitmap.width * 0.97 <= imageBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH) .combinedClickable( onLongClick = { showMenu.value = true }, @@ -132,6 +136,7 @@ fun CIImageView( // .width(DEFAULT_MAX_IMAGE_WIDTH) is a hack for image to increase IntrinsicSize of FramedItemView // if text is short and take all available width if text is long modifier = Modifier + .ui(uiKey) .width(if (painter.intrinsicSize.width * 0.97 <= painter.intrinsicSize.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH) .combinedClickable( onLongClick = { showMenu.value = true }, @@ -224,7 +229,7 @@ fun CIImageView( val (imageBitmap, data, _) = loaded SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, onClick) }) } else { - imageView(previewBitmap, onClick = { + imageView(previewBitmap, key = uiKey?.part("preview"), onClick = { if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 52f169e136..b106b640fc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -224,7 +224,8 @@ fun ChatItemView( } }, lineLimit = 1, - color = if (m.onClick == null) MaterialTheme.colors.secondary else MenuTextColor + color = if (m.onClick == null) MaterialTheme.colors.secondary else MenuTextColor, + uiKey = Ui.Chat.ReactionMember(cItem.id, m.name) ) } } @@ -273,6 +274,7 @@ fun ChatItemView( IconButton( onClick, Modifier + .ui(Ui.Chat.Item(cItem.id).part("goTo")) .padding(start = if (alignStart) 0.dp else DEFAULT_PADDING_HALF + 3.dp, end = if (alignStart) DEFAULT_PADDING_HALF + 3.dp else 0.dp) .then(if (fullyVisible) Modifier.background(mixedBackgroundColor, CircleShape) else Modifier) .size(22.dp), @@ -427,7 +429,7 @@ fun ChatItemView( } if (copyAndShareAllowed) { - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), uiKey = Ui.Chat.ItemMenu(cItem.id, "share"), onClick = { var fileSource = getLoadedFileSource(cItem.file) val shareIfExists = { when (val f = fileSource) { @@ -446,7 +448,7 @@ fun ChatItemView( }) } if (copyAndShareAllowed) { - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), uiKey = Ui.Chat.ItemMenu(cItem.id, "copy"), onClick = { copyItemToClipboard(cItem, clipboard) showMenu.value = false }) @@ -482,10 +484,10 @@ fun ChatItemView( } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) if (revealed.value) { - HideItemAction(revealed, showMenu, reveal) + HideItemAction(revealed, showMenu, Ui.Chat.ItemMenu(cItem.id, "hide"), reveal) } if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null && !cItem.localNote) { - CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) + CancelFileItemAction(cItem.file.fileId, showMenu, Ui.Chat.ItemMenu(cItem.id, "cancelFile"), cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) @@ -507,11 +509,11 @@ fun ChatItemView( cItem.meta.itemDeleted != null -> { DefaultDropdownMenu(showMenu) { if (revealed.value) { - HideItemAction(revealed, showMenu, reveal) + HideItemAction(revealed, showMenu, reveal = reveal) } else if (!cItem.isDeletedContent) { - RevealItemAction(revealed, showMenu, reveal) + RevealItemAction(revealed, showMenu, reveal = reveal) } else if (range.value != null) { - ExpandItemAction(revealed, showMenu, reveal) + ExpandItemAction(revealed, showMenu, Ui.Chat.ItemMenu(cItem.id, "expand"), reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) @@ -534,9 +536,9 @@ fun ChatItemView( cItem.mergeCategory != null && ((range.value?.count() ?: 0) > 1 || revealed.value) -> { DefaultDropdownMenu(showMenu) { if (revealed.value) { - ShrinkItemAction(revealed, showMenu, reveal) + ShrinkItemAction(revealed, showMenu, Ui.Chat.ItemMenu(cItem.id, "collapse"), reveal) } else { - ExpandItemAction(revealed, showMenu, reveal) + ExpandItemAction(revealed, showMenu, Ui.Chat.ItemMenu(cItem.id, "expand"), reveal) } DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { @@ -561,7 +563,7 @@ fun ChatItemView( fun MarkedDeletedItemDropdownMenu() { DefaultDropdownMenu(showMenu) { if (!cItem.isDeletedContent) { - RevealItemAction(revealed, showMenu, reveal) + RevealItemAction(revealed, showMenu, Ui.Chat.ItemMenu(cItem.id, "reveal"), reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) @@ -660,11 +662,11 @@ fun ChatItemView( MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { if (revealed.value) { - HideItemAction(revealed, showMenu, reveal) + HideItemAction(revealed, showMenu, reveal = reveal) } else if (!cItem.isDeletedContent) { - RevealItemAction(revealed, showMenu, reveal) + RevealItemAction(revealed, showMenu, reveal = reveal) } else if (range.value != null) { - ExpandItemAction(revealed, showMenu, reveal) + ExpandItemAction(revealed, showMenu, Ui.Chat.ItemMenu(cItem.id, "expand"), reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) @@ -837,12 +839,14 @@ expect fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL fun CancelFileItemAction( fileId: Long, showMenu: MutableState, + uiKey: UiKey? = null, cancelFile: (Long) -> Unit, cancelAction: CancelAction ) { ItemAction( stringResource(cancelAction.uiActionId), painterResource(MR.images.ic_close), + uiKey = uiKey, onClick = { showMenu.value = false cancelFileAlertDialog(fileId, cancelFile = cancelFile, cancelAction = cancelAction) @@ -861,6 +865,7 @@ fun ItemInfoAction( ItemAction( stringResource(MR.strings.info_menu), painterResource(MR.images.ic_info), + uiKey = Ui.Chat.ItemMenu(cItem.id, "info"), onClick = { showItemDetails(cInfo, cItem) showMenu.value = false @@ -956,10 +961,11 @@ fun SelectItemAction( } @Composable -private fun RevealItemAction(revealed: State, showMenu: MutableState, reveal: (Boolean) -> Unit) { +private fun RevealItemAction(revealed: State, showMenu: MutableState, uiKey: UiKey? = null, reveal: (Boolean) -> Unit) { ItemAction( stringResource(MR.strings.reveal_verb), painterResource(MR.images.ic_visibility), + uiKey = uiKey, onClick = { reveal(true) showMenu.value = false @@ -968,10 +974,11 @@ private fun RevealItemAction(revealed: State, showMenu: MutableState, showMenu: MutableState, reveal: (Boolean) -> Unit) { +private fun HideItemAction(revealed: State, showMenu: MutableState, uiKey: UiKey? = null, reveal: (Boolean) -> Unit) { ItemAction( stringResource(MR.strings.hide_verb), painterResource(MR.images.ic_visibility_off), + uiKey = uiKey, onClick = { reveal(false) showMenu.value = false @@ -980,10 +987,11 @@ private fun HideItemAction(revealed: State, showMenu: MutableState, showMenu: MutableState, reveal: (Boolean) -> Unit) { +private fun ExpandItemAction(revealed: State, showMenu: MutableState, uiKey: UiKey? = null, reveal: (Boolean) -> Unit) { ItemAction( stringResource(MR.strings.expand_verb), painterResource(MR.images.ic_expand_all), + uiKey = uiKey, onClick = { reveal(true) showMenu.value = false @@ -992,10 +1000,11 @@ private fun ExpandItemAction(revealed: State, showMenu: MutableState, showMenu: MutableState, reveal: (Boolean) -> Unit) { +private fun ShrinkItemAction(revealed: State, showMenu: MutableState, uiKey: UiKey? = null, reveal: (Boolean) -> Unit) { ItemAction( stringResource(MR.strings.hide_verb), painterResource(MR.images.ic_collapse_all), + uiKey = uiKey, onClick = { reveal(false) showMenu.value = false @@ -1012,6 +1021,7 @@ private fun ReportItemAction( ItemAction( stringResource(MR.strings.report_verb), painterResource(MR.images.ic_flag), + uiKey = Ui.Chat.ItemMenu(cItem.id, "report"), onClick = { AlertManager.shared.showAlertDialogButtons( title = generalGetString(MR.strings.report_reason_alert_title), @@ -1032,13 +1042,13 @@ private fun ReportItemAction( ) } AlertManager.shared.hideAlert() - }) { + }, uiKey = Ui.Chat.ReportReason(reason.text)) { Text(reason.text, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } } SectionItemView({ AlertManager.shared.hideAlert() - }) { + }, uiKey = Ui.Alert.Dismiss(Ui.AlertSource.Shared)) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } @@ -1054,6 +1064,7 @@ private fun ArchiveReportItemAction(id: Long, allowForAll: Boolean, showMenu: Mu ItemAction( stringResource(MR.strings.archive_report), painterResource(MR.images.ic_inventory_2), + uiKey = Ui.Chat.ItemMenu(id, "archive"), onClick = { showArchiveReportsAlert(listOf(id), allowForAll, archiveReports) showMenu.value = false @@ -1074,7 +1085,7 @@ fun showArchiveReportsAlert(ids: List, allowForAll: Boolean, archiveReport SectionItemView({ AlertManager.shared.hideAlert() archiveReports(ids, false) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "archiveForMe")) { Text( generalGetString(MR.strings.report_archive_for_me), Modifier.fillMaxWidth(), @@ -1087,7 +1098,7 @@ fun showArchiveReportsAlert(ids: List, allowForAll: Boolean, archiveReport SectionItemView({ AlertManager.shared.hideAlert() archiveReports(ids, true) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "archiveForAll")) { Text( stringResource(MR.strings.report_archive_for_all_moderators), Modifier.fillMaxWidth(), @@ -1446,13 +1457,13 @@ fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: TextButton(onClick = { deleteMessages(itemIds, false) AlertManager.shared.hideAlert() - }) { Text(stringResource(if (editorial) MR.strings.from_history else MR.strings.for_me_only), color = MaterialTheme.colors.error) } + }, Modifier.ui(Ui.Alert.Option(Ui.AlertSource.Shared, "forMe"))) { Text(stringResource(if (editorial) MR.strings.from_history else MR.strings.for_me_only), color = MaterialTheme.colors.error) } if (forAll) { TextButton(onClick = { deleteMessages(itemIds, true) AlertManager.shared.hideAlert() - }) { Text(stringResource(MR.strings.for_everybody), color = MaterialTheme.colors.error) } + }, Modifier.ui(Ui.Alert.Option(Ui.AlertSource.Shared, "forEveryone"))) { Text(stringResource(MR.strings.for_everybody), color = MaterialTheme.colors.error) } } } } 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 8b9bd15cef..5647b8cf83 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 @@ -369,7 +369,7 @@ fun FramedItemView( } else { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile) + CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, Ui.Chat.ItemFile(ci.id), receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { @@ -406,7 +406,7 @@ fun FramedItemView( is MsgContent.MCChat -> { val hasText = mc.text != mc.chatLink.connLinkStr Box( - Modifier.combinedClickable( + Modifier.ui(Ui.Chat.Item(ci.id).part("chatLink")).combinedClickable( onClick = { withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index b1604adc84..5c32b13313 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -14,7 +14,9 @@ import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.onGloballyPositioned import chat.simplex.common.model.CryptoFile import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.ui.ui import chat.simplex.common.views.chat.ProviderMedia import chat.simplex.common.views.helpers.* import kotlinx.coroutines.flow.distinctUntilChanged @@ -77,6 +79,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> val index = index - firstValidPageBeforeScrollingToStart.value Column( Modifier + .ui(Ui.Chat.FullScreenMedia) .fillMaxSize() .background(Color.Black) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = goBack) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index e917d75d99..689123ed22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -344,7 +344,7 @@ fun GroupMenuItems( ToggleNotificationsChatAction(chat, chatModel, groupInfo.chatSettings.enableNtfs.nextMode(true), showMenu) TagListAction(chat, showMenu) if (chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - ArchiveAllReportsItemAction(showMenu) { + ArchiveAllReportsItemAction(showMenu, Ui.ChatList.RowMenu(chat.id, "archiveReports", chat.remoteHostId)) { archiveAllReportsForMe(chat.remoteHostId, chat.chatInfo.apiId) } } @@ -496,7 +496,8 @@ fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, sh deleteGroupDialog(chat, groupInfo, chatModel) showMenu.value = false }, - color = Color.Red + color = Color.Red, + uiKey = Ui.ChatList.RowMenu(chat.id, "delete", chat.remoteHostId) ) } @@ -519,6 +520,7 @@ fun JoinGroupAction( if (chat.chatInfo.incognito) stringResource(MR.strings.join_group_incognito_button) else stringResource(MR.strings.join_group_button), if (chat.chatInfo.incognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_login), color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground, + uiKey = Ui.ChatList.RowMenu(chat.id, "join", chat.remoteHostId), onClick = { joinGroup() showMenu.value = false @@ -651,10 +653,11 @@ private fun InvalidDataView() { } @Composable -private fun ArchiveAllReportsItemAction(showMenu: MutableState, archiveReports: () -> Unit) { +private fun ArchiveAllReportsItemAction(showMenu: MutableState, uiKey: UiKey? = null, archiveReports: () -> Unit) { ItemAction( stringResource(MR.strings.archive_reports), painterResource(MR.images.ic_inventory_2), + uiKey = uiKey, onClick = { showArchiveAllReportsForMeAlert(archiveReports) showMenu.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 6482e21148..d6b21da571 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -357,7 +357,7 @@ fun ChatPreviewView( } is MsgContent.MCChat -> SmallContentPreview(borderColor = if (mc.chatLink.image != null) MaterialTheme.colors.onSurface.copy(alpha = 0.12f) else Color.Transparent) { Box( - Modifier.fillMaxSize().clickable { withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } }, + Modifier.fillMaxSize().ui(Ui.ChatList.Row(chat.id, chat.remoteHostId).part("link")).clickable { withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } }, contentAlignment = Alignment.Center ) { val image = mc.chatLink.image diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index e44293ad55..4e8c339818 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -187,7 +187,8 @@ private fun SMPServerView(srvSumm: SMPServerSummary, statsStartedAt: Instant, rh statsStartedAt = statsStartedAt ) } - } + }, + uiKey = Ui.ServersSummary.SmpServer(serverAddress(srvSumm.smpServer)) ) { Text( serverAddress(srvSumm.smpServer), @@ -312,7 +313,8 @@ private fun SMPStatsView(stats: AgentSMPServerStatsData, statsStartedAt: Instant stats = stats, statsStartedAt = statsStartedAt) } - } + }, + uiKey = Ui.ServersSummary.Details ) { Text(text = generalGetString(MR.strings.servers_info_details), color = MaterialTheme.colors.onBackground) } @@ -817,6 +819,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta selectedUserCategory, icon = null, enabled = remember { mutableStateOf(true) }, + uiKey = Ui.ServersSummary.UserCategory, onSelected = { selectedUserCategory.value = it } @@ -928,7 +931,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta @Composable private fun ReconnectAllServersButton(rh: RemoteHostInfo?) { - SectionItemView(click = { reconnectAllServersAlert(rh) }) { + SectionItemView(click = { reconnectAllServersAlert(rh) }, uiKey = Ui.ServersSummary.ReconnectAll) { Text( stringResource(MR.strings.servers_info_reconnect_all_servers_button), color = MaterialTheme.colors.primary @@ -957,7 +960,7 @@ private fun reconnectAllServersAlert(rh: RemoteHostInfo?) { @Composable private fun ResetStatisticsButton(rh: RemoteHostInfo?, resetStats: () -> Unit) { - SectionItemView(click = { resetStatisticsAlert(rh, resetStats) }) { + SectionItemView(click = { resetStatisticsAlert(rh, resetStats) }, uiKey = Ui.ServersSummary.ResetStats) { Text( stringResource(MR.strings.servers_info_reset_stats), color = MaterialTheme.colors.primary diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 023aea530f..a1e887f874 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -16,6 +16,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.themedBackground import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.newchat.ActiveProfilePicker @@ -182,7 +183,8 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal ) } } - } + }, + uiKey = Ui.Share.Bar ) } 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 51714edaf6..68f54aa328 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 @@ -253,7 +253,8 @@ fun UserPicker( } }() }), - disabled = stopped + disabled = stopped, + uiKey = Ui.UserPicker.ChatPreferences ) } if (appPlatform.isDesktop) { @@ -388,7 +389,8 @@ private fun GlobalSettingsSection( ConnectDesktopView(close) } }, - disabled = stopped + disabled = stopped, + uiKey = Ui.UserPicker.UseFromDesktop ) } else { UserPickerOptionRow( @@ -400,7 +402,8 @@ private fun GlobalSettingsSection( ConnectMobileView() } }, - disabled = stopped + disabled = stopped, + uiKey = Ui.UserPicker.LinkMobile ) } @@ -544,7 +547,8 @@ private fun DevicePickerRow( icon = painterResource(MR.images.ic_smartphone_300), text = activeHost.hostDeviceName, actionButtonVisible = activeHost.sessionState is RemoteHostSessionState.Connected, - onActionButtonClick = { onRemoteHostActionButtonClick(activeHost) } + onActionButtonClick = { onRemoteHostActionButtonClick(activeHost) }, + uiKey = Ui.UserPicker.Device(activeHost.remoteHostId) ) { onRemoteHostClick(activeHost, connecting) } @@ -554,7 +558,8 @@ private fun DevicePickerRow( active = localDeviceActive, icon = painterResource(MR.images.ic_desktop), text = stringResource(MR.strings.this_device), - actionButtonVisible = false + actionButtonVisible = false, + uiKey = Ui.UserPicker.Device(null) ) { onLocalDeviceClick() } @@ -567,7 +572,8 @@ private fun DevicePickerRow( icon = painterResource(MR.images.ic_smartphone_300), text = h.hostDeviceName, actionButtonVisible = h.sessionState is RemoteHostSessionState.Connected, - onActionButtonClick = { onRemoteHostActionButtonClick(h) } + onActionButtonClick = { onRemoteHostActionButtonClick(h) }, + uiKey = Ui.UserPicker.Device(h.remoteHostId) ) { onRemoteHostClick(h, connecting) } @@ -597,9 +603,11 @@ fun DevicePill( text: String, actionButtonVisible: Boolean, onActionButtonClick: (() -> Unit)? = null, + uiKey: UiKey? = null, onClick: () -> Unit) { Row( Modifier + .ui(uiKey) .clip(RoundedCornerShape(8.dp)) .border( BorderStroke(1.dp, MaterialTheme.colors.secondaryVariant), @@ -637,7 +645,7 @@ fun DevicePill( val interactionSource = remember { MutableInteractionSource() } val hovered = interactionSource.collectIsHoveredAsState().value Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier)) - IconButton(onActionButtonClick, Modifier.requiredSize(16.dp * fontSizeSqrtMultiplier)) { + IconButton(onActionButtonClick, Modifier.ui(uiKey?.part("disconnect")).requiredSize(16.dp * fontSizeSqrtMultiplier)) { Icon( painterResource(if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi), null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index 8c77d7560f..f6b76f449a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -6,6 +6,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* @@ -101,6 +102,7 @@ fun ContactListNavLinkView(chat: Chat, nextChatSelected: State, showDel disabled, selectedChat, nextChatSelected, + uiKey = Ui.NewChat.Contact(chat.id), ) } is ChatInfo.ContactRequest -> { @@ -144,6 +146,7 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState ItemAction( stringResource(MR.strings.delete_contact_menu_action), painterResource(MR.images.ic_delete), + uiKey = Ui.NewChat.ContactMenu(chat.id, "delete"), onClick = { deleteContactDialog(chat, chatModel) showMenu.value = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 8f22515370..beeb087b02 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -32,7 +32,10 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.* @@ -130,7 +133,8 @@ fun DatabaseEncryptionLayout( useKeychain.value, initialRandomDBPassphrase.value, storedKey.value, - enabled = (!initialRandomDBPassphrase.value && !progressIndicator.value) || migration + enabled = (!initialRandomDBPassphrase.value && !progressIndicator.value) || migration, + uiKey = Ui.DbPassphrase.Save ) { checked -> if (checked) { setUseKeychain(true, useKeychain, migration) @@ -151,7 +155,7 @@ fun DatabaseEncryptionLayout( PassphraseField( currentKey, generalGetString(MR.strings.current_passphrase), - modifier = Modifier.padding(horizontal = DEFAULT_PADDING), + modifier = Modifier.ui(Ui.DbPassphrase.Current).padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), ) @@ -160,7 +164,7 @@ fun DatabaseEncryptionLayout( PassphraseField( newKey, generalGetString(MR.strings.new_passphrase), - modifier = Modifier.padding(horizontal = DEFAULT_PADDING), + modifier = Modifier.ui(Ui.DbPassphrase.New).padding(horizontal = DEFAULT_PADDING), showStrength = true, isValid = ::validKey, keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), @@ -192,7 +196,7 @@ fun DatabaseEncryptionLayout( PassphraseField( confirmNewKey, generalGetString(MR.strings.confirm_new_passphrase), - modifier = Modifier.padding(horizontal = DEFAULT_PADDING), + modifier = Modifier.ui(Ui.DbPassphrase.Confirm).padding(horizontal = DEFAULT_PADDING), isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value }, contentType = newKeyContentType, keyboardActions = KeyboardActions(onDone = { @@ -201,7 +205,7 @@ fun DatabaseEncryptionLayout( }), ) - SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) { + SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight, uiKey = Ui.DbPassphrase.Update) { Text(generalGetString(if (migration) MR.strings.set_passphrase else MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } @@ -257,6 +261,7 @@ expect fun SavePassphraseSetting( minHeight: Dp = TextFieldDefaults.MinHeight, enabled: Boolean, smallPadding: Boolean = true, + uiKey: UiKey? = null, onCheckedChange: (Boolean) -> Unit, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 6abbf83226..ae85ca848d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -22,7 +22,10 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* @@ -125,7 +128,7 @@ fun DatabaseLayout( if (!chatModel.desktopNoUserNoRemote) { SectionView(stringResource(MR.strings.messages_section_title)) { - TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected) + TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), uiKey = Ui.Database.Ttl, onSelected = onChatItemTTLSelected) } SectionTextFooter( remember(currentUser?.displayName) { @@ -149,7 +152,8 @@ fun DatabaseLayout( stringResource(MR.strings.database_passphrase_and_export), click = showDatabaseManagement, iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, - disabled = operationsDisabled + disabled = operationsDisabled, + uiKey = Ui.Database.Management ) } SectionDividerSpaced() @@ -158,7 +162,8 @@ fun DatabaseLayout( val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0 SectionItemView( deleteAppFilesAndMedia, - disabled = deleteFilesDisabled + disabled = deleteFilesDisabled, + uiKey = Ui.Database.DeleteFiles ) { Text( stringResource(if (users.size > 1) MR.strings.delete_files_and_media_for_all_users else MR.strings.delete_files_and_media_all), @@ -234,7 +239,8 @@ fun DatabaseManagementView() { stringResource(MR.strings.database_passphrase), click = { ModalManager.start.showModal(cardScreen = true) { DatabaseEncryptionView(chatModel, false) } }, iconColor = if (unencrypted || (appPlatform.isDesktop && prefs.storeDBPassphrase.state.value)) WarningOrange else MaterialTheme.colors.secondary, - disabled = operationsDisabled + disabled = operationsDisabled, + uiKey = Ui.Database.Passphrase ) if (appPlatform.isDesktop) { SettingsActionItem( @@ -261,7 +267,8 @@ fun DatabaseManagementView() { }, textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary, - disabled = operationsDisabled + disabled = operationsDisabled, + uiKey = Ui.Database.Export ) SettingsActionItem( painterResource(MR.images.ic_download), @@ -269,7 +276,8 @@ fun DatabaseManagementView() { { withLongRunningApi { importArchiveLauncher.launch("application/zip") } }, textColor = Color.Red, iconColor = Color.Red, - disabled = operationsDisabled + disabled = operationsDisabled, + uiKey = Ui.Database.Import ) SettingsActionItem( painterResource(MR.images.ic_delete_forever), @@ -284,7 +292,8 @@ fun DatabaseManagementView() { }, textColor = Color.Red, iconColor = Color.Red, - disabled = operationsDisabled + disabled = operationsDisabled, + uiKey = Ui.Database.Delete ) } @@ -347,6 +356,7 @@ private fun setChatItemTTLAlert( fun TtlOptions( current: State, enabled: State, + uiKey: UiKey? = null, onSelected: (ChatItemTTL?) -> Unit, default: State? = null ) { @@ -370,6 +380,7 @@ fun TtlOptions( current, icon = null, enabled = enabled, + uiKey = uiKey, onSelected = onSelected ) } @@ -389,6 +400,7 @@ fun RunChatSetting( ) { DefaultSwitch( checked = !stopped, + modifier = Modifier.ui(Ui.Database.RunChat), onCheckedChange = { runChatSwitch -> if (runChatSwitch) { startChat() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt index ed10c0f86f..2bf40fda08 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.CustomTimeUnit import chat.simplex.common.model.timeText +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey import chat.simplex.res.* @Composable @@ -66,7 +68,8 @@ fun showCustomTimePickerDialog( SectionItemView({ AlertManager.shared.hideAlert() confirmButtonAction(selection.value) - } + }, + uiKey = Ui.TimePicker.Confirm ) { Text( confirmButtonText, @@ -88,6 +91,7 @@ fun DropdownCustomTimePickerSettingRow( customPickerTitle: String, customPickerConfirmButtonText: String, customPickerTimeUnitsLimits: List = TimeUnitLimits.defaultUnitsLimits, + uiKey: UiKey? = null, onSelected: (Int?) -> Unit ) { fun getValues(selectedValue: Int?): List = @@ -120,6 +124,8 @@ fun DropdownCustomTimePickerSettingRow( } }, dropdownSelection, + uiKey = uiKey, + optionKey = { sel -> if (sel is DropdownSelection.DropdownValue) sel.value else "custom" }, onSelected = { sel: DropdownSelection -> when (sel) { is DropdownSelection.DropdownValue -> updateValue(sel.value) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index a6ff2d233c..51ad9c2502 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -146,8 +146,8 @@ fun NavigationButtonClose(onButtonClicked: (() -> Unit)?, tintColor: Color = if } @Composable -fun ShareButton(onButtonClicked: () -> Unit) { - IconButton(onButtonClicked) { +fun ShareButton(uiKey: UiKey? = null, onButtonClicked: () -> Unit) { + IconButton(onButtonClicked, Modifier.ui(uiKey)) { Icon( painterResource(MR.images.ic_share), stringResource(MR.strings.share_verb), tint = MaterialTheme.colors.primary ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt index b646fc31fa..4858a8f485 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt @@ -34,6 +34,7 @@ fun ExposedDropDownSetting( minWidth: Dp = 200.dp, maxWidth: Dp = with(LocalDensity.current) { 180.sp.toDp() }, uiKey: UiKey? = null, + optionKey: (T) -> Any? = { it }, onSelected: (T) -> Unit ) { val expanded = remember { mutableStateOf(false) } @@ -74,7 +75,7 @@ fun ExposedDropDownSetting( onSelected(selectionOption.first) expanded.value = false }, - modifier = Modifier.ui(uiKey?.option(selectionOption.first ?: "null")), + modifier = Modifier.ui(uiKey?.option(optionKey(selectionOption.first) ?: "null")), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f) ) { Text( @@ -170,9 +171,10 @@ fun ExposedDropDownSettingRow( minWidth: Dp = 200.dp, maxWidth: Dp = with(LocalDensity.current) { 180.sp.toDp() }, uiKey: UiKey? = null, + optionKey: (T) -> Any? = { it }, onSelected: (T) -> Unit ) { SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) { - ExposedDropDownSetting(values, selection ,textColor, label = label, enabled = enabled, minWidth = minWidth, maxWidth = maxWidth, uiKey = uiKey, onSelected = onSelected) + ExposedDropDownSetting(values, selection ,textColor, label = label, enabled = enabled, minWidth = minWidth, maxWidth = maxWidth, uiKey = uiKey, optionKey = optionKey, onSelected = onSelected) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index d04b21a61e..a50e7ada70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -158,12 +158,13 @@ fun SectionViewSelectable( title: String?, currentValue: State, values: List>, + uiKey: UiKey? = null, onSelected: (T) -> Unit, ) { SectionView(title) { Column { values.forEach { item -> - SectionItemViewSpaceBetween({ onSelected(item.value) }) { + SectionItemViewSpaceBetween({ onSelected(item.value) }, uiKey = uiKey?.let { k -> item.value?.let { k.option(it) } }) { Text(item.title) if (currentValue.value == item.value) { Icon(painterResource(MR.images.ic_check), item.title, tint = MaterialTheme.colors.primary) @@ -181,6 +182,7 @@ fun SectionViewSelectableCards( title: String?, currentValue: State, values: List>, + uiKey: UiKey? = null, onSelected: (T) -> Unit, ) { SectionView(title) { @@ -190,7 +192,7 @@ fun SectionViewSelectableCards( Spacer(Modifier.height(DEFAULT_PADDING * 2f)) } values.forEach { item -> - SelectableCard(currentValue, item.value, item.title, item.description, onSelected) + SelectableCard(currentValue, item.value, item.title, item.description, icon = null, uiKey = uiKey?.option(item.value ?: "null"), onSelected = onSelected) } } } @@ -301,9 +303,10 @@ fun SectionItemWithValue( icon: Painter? = null, iconTint: Color = MaterialTheme.colors.secondary, enabled: State = mutableStateOf(true), + uiKey: UiKey? = null, onSelected: () -> Unit ) { - SettingsActionItemWithContent(icon = icon, text = title, iconColor = iconTint, click = if (enabled.value) onSelected else null, disabled = !enabled.value) { + SettingsActionItemWithContent(icon = icon, text = title, iconColor = iconTint, click = if (enabled.value) onSelected else null, disabled = !enabled.value, uiKey = uiKey) { Row( Modifier.padding(start = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -443,8 +446,8 @@ fun InfoRowTwoValues( @Composable -fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) { - SectionItemViewSpaceBetween(onClick) { +fun InfoRowEllipsis(title: String, value: String, uiKey: UiKey? = null, onClick: () -> Unit) { + SectionItemViewSpaceBetween(onClick, uiKey = uiKey) { val screenWidthDp = windowWidth() Text(title) Text( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt index 99f94e941c..117cf1e11e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SimpleButton.kt @@ -58,9 +58,10 @@ fun SimpleButton( text: String, icon: Painter, color: Color = MaterialTheme.colors.primary, disabled: Boolean, + uiKey: UiKey? = null, click: () -> Unit ) { - SimpleButtonFrame(click, disabled = disabled) { + SimpleButtonFrame(click, Modifier.ui(uiKey), disabled = disabled) { Icon( icon, text, tint = if (disabled) MaterialTheme.colors.secondary else color, modifier = Modifier.padding(end = 8.dp) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt index 957eb099d9..e07eee69a9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.input.key.* import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.unit.dp import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.chat.group.ProgressIndicator import chat.simplex.common.views.helpers.SimpleButton @@ -76,9 +77,9 @@ fun PasscodeView( } PasscodeEntry(passcode, true, Modifier.weight(1f, fill = false)) Row(Modifier.heightIn(min = 70.dp), verticalAlignment = Alignment.CenterVertically) { - SimpleButton(generalGetString(MR.strings.cancel_verb), icon = painterResource(MR.images.ic_close), disabled = !buttonsEnabled.value, click = cancel) + SimpleButton(generalGetString(MR.strings.cancel_verb), icon = painterResource(MR.images.ic_close), disabled = !buttonsEnabled.value, uiKey = Ui.Passcode.Cancel, click = cancel) Spacer(Modifier.size(20.dp)) - SimpleButton(submitLabel, icon = painterResource(MR.images.ic_done_filled), disabled = submitEnabled?.invoke(passcode.value) == false || passcode.value.length < 4 || !buttonsEnabled.value, click = submit) + SimpleButton(submitLabel, icon = painterResource(MR.images.ic_done_filled), disabled = submitEnabled?.invoke(passcode.value) == false || passcode.value.length < 4 || !buttonsEnabled.value, uiKey = Ui.Passcode.Submit, click = submit) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt index 9305e21cc4..62134a403f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasswordEntry.kt @@ -16,6 +16,9 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.platform.appPlatform +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import chat.simplex.common.ui.ui import chat.simplex.res.* @Composable @@ -41,7 +44,7 @@ fun PasscodeView(password: MutableState) { var showPasscode by rememberSaveable { mutableStateOf(false) } Text( if (password.value.isEmpty()) "" else remember(password.value, showPasscode) { splitPassword(showPasscode, password.value) }, - Modifier.padding(vertical = 10.dp).clickable { showPasscode = !showPasscode }, + Modifier.ui(Ui.Passcode.Entered).padding(vertical = 10.dp).clickable { showPasscode = !showPasscode }, style = MaterialTheme.typography.body1 ) } @@ -57,13 +60,13 @@ private fun BoxWithConstraintsScope.VerticalPasswordGrid(password: MutableState< DigitsRow(s, 7, 8, 9, password) Divider() Row(Modifier.requiredHeight(s)) { - PasswordEdit(s, painterResource(MR.images.ic_close)) { + PasswordEdit(s, painterResource(MR.images.ic_close), Ui.Passcode.Clear) { password.value = "" } VerticalDivider() PasswordDigit(s, 0, password) VerticalDivider() - PasswordEdit(s, painterResource(MR.images.ic_backspace)) { + PasswordEdit(s, painterResource(MR.images.ic_backspace), Ui.Passcode.Backspace) { password.value = password.value.dropLast(1) } } @@ -121,7 +124,7 @@ private fun PasswordDigit(size: Dp, d: Int, password: MutableState) { if (password.value.length < 16) { password.value += s } - }) { + }, uiKey = Ui.Passcode.Digit(d)) { Text( s, style = TextStyle( @@ -135,16 +138,16 @@ private fun PasswordDigit(size: Dp, d: Int, password: MutableState) { } @Composable -private fun PasswordEdit(size: Dp, image: Painter, action: () -> Unit) { - PasswordButton(size, action) { +private fun PasswordEdit(size: Dp, image: Painter, uiKey: UiKey? = null, action: () -> Unit) { + PasswordButton(size, action, uiKey) { Icon(image, null, tint = MaterialTheme.colors.secondary) } } @Composable -private fun PasswordButton(size: Dp, action: () -> Unit, content: @Composable BoxScope.() -> Unit) { +private fun PasswordButton(size: Dp, action: () -> Unit, uiKey: UiKey? = null, content: @Composable BoxScope.() -> Unit) { return Box( - Modifier.size(size) + Modifier.ui(uiKey).size(size) .background(MaterialTheme.colors.background, RoundedCornerShape(50)) .clickable { action() }, contentAlignment = Alignment.Center diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt index 7ad3076640..e23fc81d3b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.getUserServers import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import androidx.compose.ui.layout.ContentScale import chat.simplex.common.BuildConfigCommon @@ -310,7 +311,7 @@ private fun ProfileStepView( } } Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { - ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) + ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester, uiKey = Ui.NewChannel.Name) } Spacer(Modifier.height(8.dp)) @@ -333,7 +334,8 @@ private fun ProfileStepView( click = createChannel, textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary, - disabled = !canCreate + disabled = !canCreate, + uiKey = Ui.NewChannel.Create ) SectionTextFooter( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 72d5816d76..72e909de7a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers @@ -141,7 +142,7 @@ fun AddGroupLayout( ) if (!isValidDisplayName(displayName.value.trim())) { Spacer(Modifier.size(DEFAULT_PADDING_HALF)) - IconButton({ showInvalidNameAlert(mkValidName(displayName.value.trim()), displayName) }, Modifier.size(20.dp)) { + IconButton({ showInvalidNameAlert(mkValidName(displayName.value.trim()), displayName) }, Modifier.size(20.dp).ui(Ui.NewGroup.Name.part("invalid"))) { Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) } } @@ -169,7 +170,7 @@ fun AddGroupLayout( disabled = !canCreateProfile(displayName.value) ) - IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } + IncognitoToggle(incognitoPref, incognito, uiKey = Ui.NewGroup.Incognito) { ModalManager.start.showModal { IncognitoView() } } SectionTextFooter( buildAnnotatedString { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 8b145ef206..279fdf4bc3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* @@ -471,7 +472,7 @@ fun askCurrentOrIncognitoProfileAlert( withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) } - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.PrivacySensitive, "useCurrentProfile")) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) } SectionItemView({ @@ -479,7 +480,7 @@ fun askCurrentOrIncognitoProfileAlert( withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) } - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.PrivacySensitive, "useIncognito")) { Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) } if (connectOtherButton != null && connectOtherLink != null) { @@ -493,7 +494,7 @@ fun askCurrentOrIncognitoProfileAlert( SectionItemView({ AlertManager.privacySensitive.hideAlert() cleanup?.invoke() - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.PrivacySensitive, "cancel")) { Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } @@ -606,7 +607,7 @@ fun ownGroupLinkConfirmConnect( AlertManager.privacySensitive.hideAlert() openKnownGroup(chatModel, rhId, close, groupInfo) cleanup?.invoke() - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.PrivacySensitive, "openGroup")) { Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } // Use current profile @@ -615,7 +616,7 @@ fun ownGroupLinkConfirmConnect( withBGApi { connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) } - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.PrivacySensitive, "useCurrentProfile")) { Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } // Use new incognito profile diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 00fbf2db3c..8acb8beb94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -30,6 +30,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.Ui import chat.simplex.common.ui.UiKey +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.chat.topPaddingToContent @@ -190,11 +191,11 @@ private fun ModalData.NewChatSheetLayout( NewChatAction( painterResource(MR.images.ic_qr_code), if (appPlatform.isAndroid) stringResource(MR.strings.scan_paste_link) else stringResource(MR.strings.paste_link), - null, + Ui.NewChat.PasteLink, scanPaste, ), NewChatAction(painterResource(MR.images.ic_group), stringResource(MR.strings.create_group_button), Ui.NewChat.CreateGroup, createGroup), - NewChatAction(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.create_channel_button), null, createChannel), + NewChatAction(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.create_channel_button), Ui.NewChat.CreateChannel, createChannel), ) @Composable @@ -234,7 +235,8 @@ private fun ModalData.NewChatSheetLayout( }) } } - } + }, + uiKey = Ui.NewChat.Archived ) { Icon( painterResource(MR.images.ic_inventory_2), @@ -487,7 +489,7 @@ private fun ContactsSearchBar( tint = MaterialTheme.colors.secondary ) SearchTextField( - Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester), + Modifier.ui(Ui.NewChat.Search).weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester), placeholder = stringResource(MR.strings.search_or_paste_simplex_link), alwaysVisible = true, searchText = searchText, @@ -572,7 +574,7 @@ private fun ContactsSearchBar( @Composable private fun ToggleFilterButton() { val pref = remember { appPrefs.showUnreadAndFavorites } - IconButton(onClick = { pref.set(!pref.get()) }) { + IconButton(onClick = { pref.set(!pref.get()) }, modifier = Modifier.ui(Ui.NewChat.Favorites)) { val sp16 = with(LocalDensity.current) { 16.sp.toDp() } Icon( painterResource(MR.images.ic_filter_list), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index e60059bc24..b8a4d6e673 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -372,7 +372,8 @@ fun ActiveProfilePicker( } }, image = { ProfileImage(size = 42.dp, image = user.image) }, - badge = user.profile.localBadge + badge = user.profile.localBadge, + uiKey = Ui.Link.Profile(user.userId) ) } @@ -544,6 +545,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact showSearch = true, searchAlwaysVisible = true, onSearchValueChanged = { search.value = it }, + uiKey = Ui.Link.ProfileBar, content = { ActiveProfilePicker( search = search, @@ -593,7 +595,7 @@ fun ToggleShortLinkButton(short: MutableState) { if (appPrefs.developerTools.state.value) { Text( stringResource(if (short.value) MR.strings.full_link_button_text else MR.strings.short_link_button_text), - modifier = Modifier.clickable( + modifier = Modifier.ui(Ui.Link.ShortLinkToggle).clickable( interactionSource = remember { MutableInteractionSource() }, indication = null ) { short.value = !short.value }, @@ -696,7 +698,7 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRC text = generalGetString(MR.strings.the_text_you_pasted_is_not_a_link) ) } - }) { + }, uiKey = Ui.Link.Paste) { Box(Modifier.weight(1f)) { Text(stringResource(MR.strings.tap_to_paste_link), color = MaterialTheme.colors.primary) } @@ -858,6 +860,7 @@ fun strConnectTarget(str: String): ConnectTarget? { fun IncognitoToggle( incognitoPref: SharedPreference, incognito: MutableState, + uiKey: UiKey? = null, onClickInfo: () -> Unit ) { SettingsActionItemWithContent( @@ -871,6 +874,7 @@ fun IncognitoToggle( stringResource(MR.strings.incognito), painterResource(MR.images.ic_info), stopped = false, + uiKey = uiKey, onClickInfo = onClickInfo, preference = incognitoPref, preferenceState = incognito diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 01b054fb28..c9389557ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -29,6 +29,8 @@ import chat.simplex.common.model.ChatController.switchToLocalSession import chat.simplex.common.model.ChatModel.connectedToRemote import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* @@ -202,7 +204,7 @@ private fun SearchingDesktop(deviceName: String, remoteCtrls: SnapshotStateList< Text(stringResource(MR.strings.waiting_for_desktop), fontStyle = FontStyle.Italic) } SectionDividerSpaced() - DisconnectButton(stringResource(MR.strings.scan_QR_code).replace('\n', ' '), MR.images.ic_qr_code, ::disconnectDesktop) + DisconnectButton(stringResource(MR.strings.scan_QR_code).replace('\n', ' '), MR.images.ic_qr_code, onClick = ::disconnectDesktop) } @Composable @@ -271,7 +273,7 @@ private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessC SectionView(stringResource(MR.strings.verify_code_with_desktop)) { SessionCodeText(sessCode) - SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) { + SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }, uiKey = Ui.Remote.Confirm) { Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary) TextIconSpaced(false) Text(generalGetString(MR.strings.confirm_verb)) @@ -333,7 +335,7 @@ private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close: SectionDividerSpaced() SectionView { - DisconnectButton { disconnectDesktop(close) } + DisconnectButton(uiKey = Ui.Remote.Disconnect) { disconnectDesktop(close) } } } @@ -381,6 +383,7 @@ private fun DesktopAddressView(sessionAddress: MutableState) { click = { sessionAddress.value = clipboard.getText()?.text ?: "" }, + uiKey = Ui.Remote.PasteAddress, ) } else { Row(Modifier.padding(horizontal = DEFAULT_PADDING).fillMaxWidth()) { @@ -408,6 +411,7 @@ private fun DesktopAddressView(sessionAddress: MutableState) { click = { withBGApi { connectDesktopAddress(sessionAddress, sessionAddress.value) } }, + uiKey = Ui.Remote.Connect, ) } } @@ -537,8 +541,8 @@ private fun verifyDesktopSessionCode(remoteCtrls: SnapshotStateList Unit) { - SectionItemView(onClick) { +private fun DisconnectButton(label: String = generalGetString(MR.strings.disconnect_remote_host), icon: ImageResource = MR.images.ic_close, uiKey: UiKey? = null, onClick: () -> Unit) { + SectionItemView(onClick, uiKey = uiKey) { Icon(painterResource(icon), label, tint = MaterialTheme.colors.secondary) TextIconSpaced(false) Text(label) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 004611b842..d1ee6569a1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -140,7 +140,7 @@ fun ConnectMobileLayout( } } } - SectionItemView(addMobileDevice) { + SectionItemView(addMobileDevice, uiKey = Ui.Remote.AddMobile) { Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.link_a_mobile), tint = MaterialTheme.colors.primary) Spacer(Modifier.padding(horizontal = 10.dp)) Text(stringResource(MR.strings.link_a_mobile), color = MaterialTheme.colors.primary) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index b3ddb75d1c..a2b58de3e9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -337,6 +337,7 @@ object AppearanceScope { val checked = (background == null && (selectedWallpaper == null || selectedWallpaper == WallpaperType.Empty)) || selectedWallpaper?.samePreset(background) == true Box( Modifier + .ui(Ui.Appearance.Wallpaper(background?.filename ?: "none")) .size(width, height) .clip(RoundedCornerShape(percent = cornerRadius)) .border(1.dp, if (checked) MaterialTheme.colors.primary.copy(0.8f) else MaterialTheme.colors.onBackground.copy(if (isInDarkTheme()) 0.2f else 0.1f), RoundedCornerShape(percent = cornerRadius)) @@ -375,6 +376,7 @@ object AppearanceScope { .size(width, height) .clip(RoundedCornerShape(percent = cornerRadius)) .border(1.dp, if (type is WallpaperType.Image) MaterialTheme.colors.primary.copy(0.8f) else MaterialTheme.colors.onBackground.copy(0.1f), RoundedCornerShape(percent = cornerRadius)) + .ui(Ui.Appearance.WallpaperImage) .clickable { onChooseType(WallpaperType.Image("", null, null)) }, contentAlignment = Alignment.Center ) { @@ -549,7 +551,7 @@ object AppearanceScope { ) val type = MaterialTheme.wallpaper.type if (type is WallpaperType.Image && (themeUserDestination.value == null || perUserTheme.value.wallpaper?.imageFile != null)) { - SectionItemView(disabled = chatModel.remoteHostId != null && themeUserDestination.value != null, click = { + SectionItemView(disabled = chatModel.remoteHostId != null && themeUserDestination.value != null, uiKey = Ui.Appearance.RemoveImage, click = { if (themeUserDestination.value == null) { val defaultActiveTheme = ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) ThemeManager.saveAndApplyWallpaper(baseTheme, null) @@ -607,7 +609,7 @@ object AppearanceScope { UserWallpaperEditorModal(chatModel.remoteHostId(), user.first, close) } } - }) { + }, uiKey = Ui.Appearance.Customize) { Text(stringResource(MR.strings.customize_theme_title)) } } @@ -694,7 +696,7 @@ object AppearanceScope { SectionItemView({ ThemeManager.resetAllThemeColors() saveThemeToDatabase(null) - }) { + }, uiKey = Ui.Appearance.ResetColors) { Text(generalGetString(MR.strings.reset_color), color = colors.primary) } } @@ -717,7 +719,7 @@ object AppearanceScope { // Removing theme id without using custom serializer or data class theme.value = lines.subList(1, lines.size).joinToString("\n") withLongRunningApi { exportThemeLauncher.launch("simplex.theme") } - }) { + }, uiKey = Ui.Appearance.ExportTheme) { Text(generalGetString(MR.strings.export_theme), color = colors.primary) } val importThemeLauncher = rememberFileChooserLauncher(true) { to: URI? -> @@ -730,7 +732,7 @@ object AppearanceScope { } } // Can not limit to YAML mime type since it's unsupported by Android - SectionItemView({ withLongRunningApi { importThemeLauncher.launch("*/*") } }) { + SectionItemView({ withLongRunningApi { importThemeLauncher.launch("*/*") } }, uiKey = Ui.Appearance.ImportTheme) { Text(generalGetString(MR.strings.import_theme), color = colors.primary) } } @@ -755,6 +757,7 @@ object AppearanceScope { } Box( modifier = Modifier + .ui(Ui.UserPicker.ColorMode) .clip(CircleShape) .combinedClickable( onClick = { @@ -931,6 +934,7 @@ object AppearanceScope { themeUserDest, icon = null, enabled = remember { mutableStateOf(true) }, + uiKey = Ui.Appearance.ApplyTo, onSelected = { userId -> themeUserDest.value = userId if (userId != null) { @@ -955,7 +959,7 @@ object AppearanceScope { @Composable fun CustomizeThemeColorsSection(currentTheme: ThemeManager.ActiveTheme, editColor: (ThemeColor) -> Unit) { SectionView(stringResource(MR.strings.theme_colors_section_title)) { - SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY) }) { + SectionItemViewSpaceBetween({ editColor(ThemeColor.PRIMARY) }, uiKey = Ui.Appearance.Accent) { val title = generalGetString(MR.strings.color_primary) Text(title) Icon(painterResource(MR.images.ic_circle_filled), title, tint = currentTheme.colors.primary) @@ -1068,7 +1072,7 @@ object AppearanceScope { textFieldState.value = textFieldState.value.copy(hexTrimmed) } DefaultBasicTextField( - Modifier.fillMaxWidth(), + Modifier.fillMaxWidth().ui(Ui.Appearance.ColorHex), textFieldState, leadingIcon = { IconButton(onClick = { clipboard.shareText(hexTrimmed) }) { @@ -1140,6 +1144,7 @@ object AppearanceScope { state, icon = null, enabled = remember { mutableStateOf(true) }, + uiKey = Ui.Appearance.Language, onSelected = onSelected ) } @@ -1181,6 +1186,7 @@ object AppearanceScope { state, icon = null, enabled = remember { mutableStateOf(true) }, + uiKey = Ui.Appearance.DarkModeColors, onSelected = { if (it != null) onSelected(it) } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt index 1c55d1ebff..ff0fcac875 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/CallSettings.kt @@ -106,6 +106,7 @@ fun SharedPreferenceToggleWithIcon( text: String, icon: Painter, stopped: Boolean = false, + uiKey: UiKey? = null, onClickInfo: () -> Unit, preference: SharedPreference, preferenceState: MutableState? = null @@ -126,6 +127,7 @@ fun SharedPreferenceToggleWithIcon( preference.set(it) prefState.value = it }, + modifier = Modifier.ui(uiKey), enabled = !stopped ) } @@ -137,6 +139,7 @@ fun SharedPreferenceToggleWithIcon( icon: Painter, onClickInfo: () -> Unit, checked: Boolean, + uiKey: UiKey? = null, onCheckedChange: (Boolean) -> Unit, ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -151,6 +154,7 @@ fun SharedPreferenceToggleWithIcon( DefaultSwitch( checked = checked, onCheckedChange = onCheckedChange, + modifier = Modifier.ui(uiKey), ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index fce6c69033..e962b5e06d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import chat.simplex.common.platform.* import dev.icerock.moko.resources.compose.painterResource @@ -31,7 +32,7 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> InstallTerminalAppItem(uriHandler) ChatConsoleItem { withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.start.showModalCloseable { TerminalView(false) } } } ResetHintsItem(unchangedHints) - SettingsPreferenceItem(painterResource(MR.images.ic_code), stringResource(MR.strings.show_developer_options), developerTools) + SettingsPreferenceItem(painterResource(MR.images.ic_code), stringResource(MR.strings.show_developer_options), developerTools, uiKey = Ui.DevTools.ShowOptions) } SectionTextFooter( generalGetString(if (devTools.value) MR.strings.show_dev_options else MR.strings.hide_dev_options) + " " + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 083e5a07ae..0459b63f99 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.background import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.stringResource @@ -45,7 +46,7 @@ fun NotificationsSettingsLayout( AppBarTitle(stringResource(MR.strings.notifications)) SectionView(null) { if (appPlatform == AppPlatform.ANDROID) { - SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notifications_mode_title), showNotificationsMode) { + SettingsActionItemWithContent(null, stringResource(MR.strings.settings_notifications_mode_title), showNotificationsMode, uiKey = Ui.Notifications.Mode) { Text( modes.firstOrNull { it.value == notificationsMode.value }?.title ?: "", maxLines = 1, @@ -70,7 +71,7 @@ fun NotificationsModeView( val modes = remember { notificationModes() } ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.settings_notifications_mode_title).lowercase().capitalize(Locale.current)) - SectionViewSelectable(null, notificationsMode, modes, onNotificationsModeSelected) + SectionViewSelectable(null, notificationsMode, modes, Ui.Notifications.Mode, onNotificationsModeSelected) if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) } @@ -85,7 +86,7 @@ fun NotificationPreviewView( val previewModes = remember { notificationPreviewModes() } ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.settings_notification_preview_title)) - SectionViewSelectable(null, notificationPreviewMode, previewModes, onNotificationPreviewModeSelected) + SectionViewSelectable(null, notificationPreviewMode, previewModes, onSelected = onNotificationPreviewModeSelected) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index 6a06432c6d..1664030a17 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.ui.Modifier +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import androidx.compose.material.Text import androidx.compose.runtime.* @@ -122,6 +123,7 @@ private fun FeatureSection(feature: ChatFeature, allowFeature: State Unit, save: () -> Unit, disabled: Boolean) { SectionView { - SectionItemView(reset, disabled = disabled) { + SectionItemView(reset, disabled = disabled, uiKey = Ui.UserPrefs.Reset) { Text(stringResource(MR.strings.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } - SectionItemView(save, disabled = disabled) { + SectionItemView(save, disabled = disabled, uiKey = Ui.UserPrefs.Save) { Text(stringResource(MR.strings.save_and_notify_contacts), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 4abaa78370..b218f421b4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.usersettings import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import SectionBottomSpacer import SectionItemView import SectionDividerSpaced @@ -68,18 +69,20 @@ fun PrivacySettingsView( painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews, + uiKey = Ui.Privacy.LinkPreviews, onChange = { _ -> chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false) } // to avoid showing alert to current users, show alert in v6.5 ) SettingsPreferenceItem( painterResource(MR.images.ic_link), stringResource(MR.strings.sanitize_links_toggle), - chatModel.controller.appPrefs.privacySanitizeLinks + chatModel.controller.appPrefs.privacySanitizeLinks, + uiKey = Ui.Privacy.SanitizeLinks ) } SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_files)) { - SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) + SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages, uiKey = Ui.Privacy.AcceptImages) BlurRadiusOptions(remember { appPrefs.privacyMediaBlurRadius.state }) { appPrefs.privacyMediaBlurRadius.set(it) } @@ -137,6 +140,7 @@ fun MorePrivacyView(chatModel: ChatModel) { painterResource(MR.images.ic_edit_note), stringResource(MR.strings.privacy_message_draft), chatModel.controller.appPrefs.privacySaveLastDraft, + uiKey = Ui.Privacy.SaveDraft, onChange = { saveDraft -> if (!saveDraft) { chatModel.draft.value = null @@ -158,7 +162,7 @@ fun MorePrivacyView(chatModel: ChatModel) { SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_files)) { - SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles, onChange = { enable -> + SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles, uiKey = Ui.Privacy.EncryptLocalFiles, onChange = { enable -> withBGApi { chatModel.controller.apiSetEncryptLocalFiles(enable) } }) SettingsPreferenceItem(painterResource(MR.images.ic_security), stringResource(MR.strings.protect_ip_address), chatModel.controller.appPrefs.privacyAskToApproveRelays) @@ -331,6 +335,7 @@ private fun BlurRadiusOptions(state: State, onSelected: (Int) -> Unit) { values, state, icon = painterResource(MR.images.ic_blur_on), + uiKey = Ui.Privacy.BlurMedia, onSelected = onSelected ) } @@ -354,7 +359,8 @@ private fun AutoAcceptSection( checked = currentUser.autoAcceptMemberContacts, onCheckedChange = { enable -> setAutoAcceptMemberContacts(enable) - } + }, + modifier = Modifier.ui(Ui.Privacy.AutoAcceptMemberContacts) ) } SettingsActionItemWithContent(painterResource(MR.images.ic_group), stringResource(MR.strings.group_invitations)) { @@ -362,7 +368,8 @@ private fun AutoAcceptSection( checked = currentUser.autoAcceptGroupInvitations, onCheckedChange = { enable -> setAutoAcceptGroupInvitations(enable) - } + }, + modifier = Modifier.ui(Ui.Privacy.AutoAcceptGroupInvitations) ) } } @@ -391,7 +398,8 @@ private fun DeliveryReceiptsSection( checked = currentUser.sendRcptsContacts, onCheckedChange = { enable -> setOrAskSendReceiptsContacts(enable) - } + }, + modifier = Modifier.ui(Ui.Privacy.ReceiptsContacts) ) } SettingsActionItemWithContent(painterResource(MR.images.ic_group), stringResource(MR.strings.receipts_section_groups)) { @@ -399,7 +407,8 @@ private fun DeliveryReceiptsSection( checked = currentUser.sendRcptsSmallGroups, onCheckedChange = { enable -> setOrAskSendReceiptsGroups(enable) - } + }, + modifier = Modifier.ui(Ui.Privacy.ReceiptsGroups) ) } } @@ -688,7 +697,7 @@ fun SimplexLockView( if (performLA.value) { LockDelaySelector(remember { laLockDelay.state }) { laLockDelay.set(it) } if (showChangePasscode.value && laMode.value == LAMode.PASSCODE) { - SectionItemView({ changeLAPassword() }) { + SectionItemView({ changeLAPassword() }, uiKey = Ui.Lock.ChangePasscode) { Text( generalGetString(MR.strings.la_change_app_passcode), color = MaterialTheme.colors.primary @@ -705,12 +714,13 @@ fun SimplexLockView( SelfDestructInfoView() } } - SettingsActionItemWithContent(null, null, click = openInfo) { + SettingsActionItemWithContent(null, null, click = openInfo, uiKey = Ui.Lock.SelfDestructInfo) { SharedPreferenceToggleWithIcon( stringResource(MR.strings.enable_self_destruct), painterResource(MR.images.ic_info), openInfo, - remember { selfDestructPref.state }.value + remember { selfDestructPref.state }.value, + uiKey = Ui.Lock.SelfDestruct ) { toggleSelfDestruct(selfDestructPref) } @@ -723,7 +733,7 @@ fun SimplexLockView( fontSize = 16.sp, modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) ) - ProfileNameField(selfDestructDisplayName, "", { isValidDisplayName(it.trim()) }) + ProfileNameField(selfDestructDisplayName, "", { isValidDisplayName(it.trim()) }, uiKey = Ui.Lock.SelfDestructName) LaunchedEffect(selfDestructDisplayName.value) { val new = selfDestructDisplayName.value if (isValidDisplayName(new) && selfDestructDisplayNamePref.get() != new) { @@ -731,7 +741,7 @@ fun SimplexLockView( } } } - SectionItemView({ changeSelfDestructPassword() }) { + SectionItemView({ changeSelfDestructPassword() }, uiKey = Ui.Lock.ChangeSelfDestruct) { Text( stringResource(MR.strings.change_self_destruct_passcode), color = MaterialTheme.colors.primary @@ -791,6 +801,7 @@ private fun EnableLock(performLA: State, onCheckedChange: (Boolean) -> DefaultSwitch( checked = performLA.value, onCheckedChange = onCheckedChange, + modifier = Modifier.ui(Ui.Lock.Enable), ) } } @@ -819,6 +830,7 @@ private fun LockDelaySelector(state: State, onSelected: (Int) -> Unit) { state, icon = null, enabled = remember { mutableStateOf(true) }, + uiKey = Ui.Lock.Delay, onSelected = onSelected ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt index 003300a033..4b5e125ecc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.res.* import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.* @@ -97,14 +99,14 @@ private fun SetDeliveryReceiptsLayout( @Composable private fun EnableReceiptsButton(onClick: () -> Unit) { - TextButton(onClick) { + TextButton(onClick, Modifier.ui(Ui.DeliveryReceipts.Enable)) { Text(stringResource(MR.strings.enable_receipts_all), style = MaterialTheme.typography.h2, color = MaterialTheme.colors.primary) } } @Composable private fun SkipButton(onClick: () -> Unit) { - SimpleButtonIconEnded(stringResource(MR.strings.dont_enable_receipts), painterResource(MR.images.ic_chevron_right), click = onClick) + SimpleButtonIconEnded(stringResource(MR.strings.dont_enable_receipts), painterResource(MR.images.ic_chevron_right), uiKey = Ui.DeliveryReceipts.Skip, click = onClick) TextBelowButton(stringResource(MR.strings.you_can_enable_delivery_receipts_later)) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 0bf1c8e9b6..088b8d54e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -27,6 +27,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.Ui import chat.simplex.common.ui.UiKey +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.badges.BadgesView import chat.simplex.common.views.database.DatabaseView @@ -105,7 +106,7 @@ fun SettingsLayout( SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.your_privacy), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, uiKey = Ui.Settings.Privacy) SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.help_and_support), showSettingsModal { HelpAndSupportView(it, showModal, showCustomModal) }, uiKey = Ui.Settings.Help) DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView() }, stopped) - SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped, uiKey = Ui.Settings.Migrate) } SectionDividerSpaced() @@ -122,11 +123,11 @@ fun SettingsLayout( SectionDividerSpaced() SectionView(stringResource(MR.strings.advanced_settings)) { - SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped, uiKey = Ui.Settings.Network) if (appPlatform == AppPlatform.ANDROID) { SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped) } - SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, uiKey = Ui.Settings.Calls) AppShutdownItem() AppVersionItem(showVersion) } @@ -158,9 +159,9 @@ fun HelpAndSupportView( AppBarTitle(stringResource(MR.strings.help_and_support)) SectionView(stringResource(MR.strings.settings_section_title_help)) { - SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped) + SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName) }, disabled = stopped, uiKey = Ui.Help.HowToUse) SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close = close) }, disabled = stopped, uiKey = Ui.Help.WhatsNew) - SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }) + SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }, uiKey = Ui.Help.About) } SectionDividerSpaced() @@ -169,7 +170,7 @@ fun HelpAndSupportView( SettingsActionItem(painterResource(MR.images.ic_bigtop_updates), stringResource(MR.strings.follow_simplex_network_news), { uriHandler.openVerifiedSimplexUri(simplexNewsUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) } - SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary) + SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary, uiKey = Ui.Help.Email) } SectionDividerSpaced() @@ -197,7 +198,7 @@ expect fun AdvancedSettingsAppSection( expect fun AppShutdownItem() @Composable private fun DatabaseItem(encrypted: Boolean, saved: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { - SectionItemView(openDatabaseView) { + SectionItemView(openDatabaseView, uiKey = Ui.Settings.Database) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -248,14 +249,15 @@ fun ChatLockItem( click = showSettingsModal { SimplexLockView(ChatModel, currentLAMode, setPerformLA) }, icon = if (performLA.value) painterResource(MR.images.ic_lock_filled) else painterResource(MR.images.ic_lock), text = stringResource(MR.strings.chat_lock), - iconColor = if (performLA.value) SimplexGreen else MaterialTheme.colors.secondary + iconColor = if (performLA.value) SimplexGreen else MaterialTheme.colors.secondary, + uiKey = Ui.Privacy.Lock ) { Text(if (performLA.value) remember { currentLAMode.state }.value.text else generalGetString(MR.strings.la_mode_off), color = MaterialTheme.colors.secondary) } } @Composable fun ContributeItem(uriHandler: UriHandler) { - SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat#contribute") }) { + SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat#contribute") }, uiKey = Ui.Help.Contribute) { Icon( painterResource(MR.images.ic_keyboard), contentDescription = "GitHub", @@ -283,7 +285,7 @@ fun ChatLockItem( } @Composable fun StarOnGithubItem(uriHandler: UriHandler) { - SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat") }) { + SectionItemView({ uriHandler.openExternalLink("https://github.com/simplex-chat/simplex-chat") }, uiKey = Ui.Help.Star) { Icon( painter = painterResource(MR.images.ic_github), contentDescription = "GitHub", @@ -295,7 +297,7 @@ fun ChatLockItem( } @Composable fun ChatConsoleItem(showTerminal: () -> Unit) { - SectionItemView(showTerminal) { + SectionItemView(showTerminal, uiKey = Ui.DevTools.Console) { Icon( painter = painterResource(MR.images.ic_outline_terminal), contentDescription = stringResource(MR.strings.chat_console), @@ -331,7 +333,7 @@ fun ChatLockItem( SectionItemView({ resetHintPreferences() unchangedHints.value = true - }, disabled = unchangedHints.value) { + }, disabled = unchangedHints.value, uiKey = Ui.DevTools.ResetHints) { Icon( painter = painterResource(MR.images.ic_lightbulb), contentDescription = "Lightbulb", @@ -350,7 +352,7 @@ fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { i @Composable fun AppVersionItem(showVersion: () -> Unit) { - SectionItemView(showVersion) { AppVersionText() } + SectionItemView(showVersion, uiKey = Ui.Settings.Version) { AppVersionText() } } @Composable fun AppVersionText() { @@ -392,7 +394,7 @@ fun SettingsActionItem(icon: Painter, text: String, click: (() -> Unit)? = null, } @Composable -fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, textColor: Color = MaterialTheme.colors.onBackground, disabled: Boolean = false, extraPadding: Boolean = false, content: @Composable RowScope.() -> Unit) { +fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: (() -> Unit)? = null, iconColor: Color = MaterialTheme.colors.secondary, textColor: Color = MaterialTheme.colors.onBackground, disabled: Boolean = false, extraPadding: Boolean = false, uiKey: UiKey? = null, content: @Composable RowScope.() -> Unit) { SectionItemView( click, extraPadding = extraPadding, @@ -400,7 +402,8 @@ fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: ( PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding) else PaddingValues(horizontal = itemHPadding), - disabled = disabled + disabled = disabled, + uiKey = uiKey ) { if (icon != null) { Icon(icon, text, Modifier, tint = if (disabled) MaterialTheme.colors.secondary else iconColor) @@ -441,12 +444,14 @@ fun PreferenceToggle( text: String, disabled: Boolean = false, checked: Boolean, + uiKey: UiKey? = null, onChange: (Boolean) -> Unit = {}, ) { SettingsActionItemWithContent(null, text, disabled = disabled) { DefaultSwitch( checked = checked, onCheckedChange = onChange, + modifier = Modifier.ui(uiKey), enabled = !disabled ) } @@ -460,6 +465,7 @@ fun PreferenceToggleWithIcon( disabled: Boolean = false, checked: Boolean, extraPadding: Boolean = false, + uiKey: UiKey? = null, onChange: (Boolean) -> Unit = {}, ) { SettingsActionItemWithContent(icon, text, iconColor = iconColor ?: MaterialTheme.colors.secondary, extraPadding = extraPadding) { @@ -468,6 +474,7 @@ fun PreferenceToggleWithIcon( onCheckedChange = { onChange(it) }, + modifier = Modifier.ui(uiKey), enabled = !disabled ) } @@ -501,6 +508,7 @@ fun doWithAuth(title: String, desc: String, block: () -> Unit) { SimpleButton( stringResource(MR.strings.auth_unlock), icon = painterResource(MR.images.ic_lock), + uiKey = Ui.Lock.Unlock, click = { runAuth(title, desc, onFinishAuth) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index b065f9c736..a55fa976bc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -26,6 +26,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel @@ -457,6 +458,7 @@ private fun ShareViaChatButton(onClick: () -> Unit) { onClick, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, + uiKey = Ui.Address.ShareViaChat, ) } @@ -473,6 +475,7 @@ private fun CreateOneTimeLinkButton() { }, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, + uiKey = Ui.Address.CreateLink, ) } @@ -482,6 +485,7 @@ private fun LearnMoreButton(onClick: () -> Unit) { painterResource(MR.images.ic_info), stringResource(MR.strings.simplex_address_or_1_time_link), onClick, + uiKey = Ui.Address.LearnMore, ) } @@ -511,7 +515,8 @@ private fun AddressSettingsButton( ModalManager.start.showCustomModal { close -> UserAddressSettings(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings, close = close) } - } + }, + uiKey = Ui.Address.Settings, ) } @@ -589,6 +594,7 @@ fun ShareWithContactsButton(shareViaProfile: MutableState, setProfileAd stringResource(MR.strings.share_with_contacts), painterResource(MR.images.ic_person), checked = shareViaProfile.value, + uiKey = Ui.Address.ShareWithContacts, ) { on -> shareViaProfile.value = on if (on) { @@ -629,6 +635,7 @@ private fun BusinessAddressToggle(addressSettingsState: MutableState addressSettingsState.value = if (businessToggle) AddressSettingsState( @@ -654,7 +661,8 @@ private fun AutoAcceptToggle(addressSettingsState: MutableState addressSettingsState.value = if (autoAcceptToggle) AddressSettingsState( @@ -682,6 +690,7 @@ private fun DeleteAddressButton(onClick: () -> Unit) { onClick, iconColor = MaterialTheme.colors.error, textColor = MaterialTheme.colors.error, + uiKey = Ui.Address.Delete, ) } @@ -759,7 +768,7 @@ private fun AutoReplyEditor(addressSettingsState: MutableState Unit) { - SectionItemView(onClick, disabled = disabled) { + SectionItemView(onClick, disabled = disabled, uiKey = Ui.Address.SaveSettings) { Text(stringResource(MR.strings.save_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index d7234ec04b..170f79eb88 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -146,7 +146,7 @@ fun UserProfileLayout( Box(Modifier.weight(1f, fill = false).padding(bottom = DEFAULT_PADDING)) { TextEditor( description, - Modifier.heightIn(min = 140.dp), + Modifier.heightIn(min = 140.dp).ui(Ui.Profile.DescriptionText), placeholder = stringResource(MR.strings.enter_description_optional), contentPadding = PaddingValues(), focusRequester = descrFocusRequester, @@ -194,7 +194,7 @@ fun UserProfileLayout( ) if (!isValidNewProfileName(displayName.value, profile)) { Spacer(Modifier.width(DEFAULT_PADDING_HALF)) - IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) { + IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp).ui(Ui.Profile.Name.part("invalid"))) { Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) } } @@ -207,7 +207,7 @@ fun UserProfileLayout( fontSize = 16.sp, modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) ) - ProfileNameField(fullName) + ProfileNameField(fullName, uiKey = Ui.Profile.FullName) } Spacer(Modifier.height(DEFAULT_PADDING)) @@ -221,19 +221,19 @@ fun UserProfileLayout( Spacer(Modifier.size(DEFAULT_PADDING_HALF)) IconButton( onClick = { AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.bio_too_large)) }, - Modifier.size(20.dp) + Modifier.size(20.dp).ui(Ui.Profile.Bio.part("invalid")) ) { Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) } } } - ProfileNameField(shortDescr) + ProfileNameField(shortDescr, uiKey = Ui.Profile.Bio) Spacer(Modifier.height(DEFAULT_PADDING)) Text( stringResource(if (description.value.isBlank()) MR.strings.add_description else MR.strings.edit_description), color = MaterialTheme.colors.primary, - modifier = Modifier.clickable { editingDescription = true } + modifier = Modifier.ui(Ui.Profile.Description).clickable { editingDescription = true } ) Spacer(Modifier.height(DEFAULT_PADDING)) 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 0d56ec7a96..c549e2cd04 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 @@ -23,6 +23,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.UserProfilePickerItem @@ -224,7 +225,7 @@ private fun UserView( Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { DefaultDropdownMenu(showMenu) { if (user.hidden) { - ItemAction(stringResource(MR.strings.user_unhide), painterResource(MR.images.ic_lock_open_right), onClick = { + ItemAction(stringResource(MR.strings.user_unhide), painterResource(MR.images.ic_lock_open_right), uiKey = Ui.Profiles.UserMenu(user.userId, "unhide"), onClick = { showMenu.value = false unhideUser(user) }) @@ -236,12 +237,12 @@ private fun UserView( }) } if (user.showNtfs) { - ItemAction(stringResource(MR.strings.user_mute), painterResource(MR.images.ic_notifications_off), onClick = { + ItemAction(stringResource(MR.strings.user_mute), painterResource(MR.images.ic_notifications_off), uiKey = Ui.Profiles.UserMenu(user.userId, "mute"), onClick = { showMenu.value = false muteUser(user) }) } else { - ItemAction(stringResource(MR.strings.user_unmute), painterResource(MR.images.ic_notifications), onClick = { + ItemAction(stringResource(MR.strings.user_unmute), painterResource(MR.images.ic_notifications), uiKey = Ui.Profiles.UserMenu(user.userId, "unmute"), onClick = { showMenu.value = false unmuteUser(user) }) @@ -278,9 +279,9 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: ( @Composable fun PasswordAndAction(label: StringResource, color: Color = MaterialTheme.colors.primary) { SectionView() { SectionItemViewWithoutMinPadding { - PassphraseField(actionPassword, generalGetString(MR.strings.profile_password), isValid = { passwordValid }, showStrength = true, contentType = null) + PassphraseField(actionPassword, generalGetString(MR.strings.profile_password), modifier = Modifier.ui(Ui.ProfileAction.Password), isValid = { passwordValid }, showStrength = true, contentType = null) } - SectionItemViewSpaceBetween({ doAction(actionPassword.value) }, disabled = !actionEnabled, minHeight = TextFieldDefaults.MinHeight) { + SectionItemViewSpaceBetween({ doAction(actionPassword.value) }, disabled = !actionEnabled, minHeight = TextFieldDefaults.MinHeight, uiKey = Ui.ProfileAction.Submit) { Text(generalGetString(label), color = if (actionEnabled) color else MaterialTheme.colors.secondary) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 3a6fc0fb5c..bbab4b51b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -23,7 +23,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.ColumnWithScrollBar @@ -259,32 +262,32 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeoutInteractive, - listOf(10_000000, 15_000000, 20_000000, 30_000000), secondsLabel + listOf(10_000000, 15_000000, 20_000000, 30_000000), secondsLabel, Ui.AdvancedNetwork.Setting("tcpConnectTimeout") ) } SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_tcp_connection_timeout_background), networkTCPConnectTimeoutBackground, - listOf(30_000000, 45_000000, 60_000000, 90_000000), secondsLabel + listOf(30_000000, 45_000000, 60_000000, 90_000000), secondsLabel, Ui.AdvancedNetwork.Setting("tcpConnectTimeoutBackground") ) } SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_protocol_timeout), networkTCPTimeoutInteractive, - listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000), secondsLabel + listOf(5_000000, 7_000000, 10_000000, 15_000000, 20_000_000), secondsLabel, Ui.AdvancedNetwork.Setting("tcpTimeout") ) } SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_protocol_timeout_background), networkTCPTimeoutBackground, - listOf(15_000000, 20_000000, 30_000000, 45_000000, 60_000_000), secondsLabel + listOf(15_000000, 20_000000, 30_000000, 45_000000, 60_000_000), secondsLabel, Ui.AdvancedNetwork.Setting("tcpTimeoutBackground") ) } SectionItemView { // can't be higher than 130ms to avoid overflow on 32bit systems TimeoutSettingRow( stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, - listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel + listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel, Ui.AdvancedNetwork.Setting("tcpTimeoutPerKb") ) } // SectionItemView { @@ -296,13 +299,13 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_ping_interval), networkSMPPingInterval, - listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel + listOf(120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000), secondsLabel, Ui.AdvancedNetwork.Setting("smpPingInterval") ) } SectionItemView { IntSettingRow( stringResource(MR.strings.network_option_ping_count), networkSMPPingCount, - listOf(1, 2, 3, 5, 8), "" + listOf(1, 2, 3, 5, 8), "", Ui.AdvancedNetwork.Setting("smpPingCount") ) } SectionItemView { @@ -310,13 +313,13 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - } if (networkEnableKeepAlive.value) { SectionItemView { - IntSettingRow("TCP_KEEPIDLE", networkTCPKeepIdle, listOf(15, 30, 60, 120, 180), secondsLabel) + IntSettingRow("TCP_KEEPIDLE", networkTCPKeepIdle, listOf(15, 30, 60, 120, 180), secondsLabel, Ui.AdvancedNetwork.Setting("tcpKeepIdle")) } SectionItemView { IntSettingRow("TCP_KEEPINTVL", networkTCPKeepIntvl, listOf(5, 10, 15, 30, 60), secondsLabel) } SectionItemView { - IntSettingRow("TCP_KEEPCNT", networkTCPKeepCnt, listOf(1, 2, 4, 6, 8), "") + IntSettingRow("TCP_KEEPCNT", networkTCPKeepCnt, listOf(1, 2, 4, 6, 8), "", Ui.AdvancedNetwork.Setting("tcpKeepCnt")) } } else { SectionItemView { @@ -335,10 +338,10 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - SectionDividerSpaced() SectionView { - SectionItemView(reset, disabled = resetDisabled) { + SectionItemView(reset, disabled = resetDisabled, uiKey = Ui.AdvancedNetwork.Reset) { Text(stringResource(MR.strings.network_options_reset_to_defaults), color = if (resetDisabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } - SectionItemView(save, disabled = saveDisabled) { + SectionItemView(save, disabled = saveDisabled, uiKey = Ui.AdvancedNetwork.Save) { Text(stringResource(MR.strings.network_options_save_and_reconnect), color = if (saveDisabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } } @@ -369,11 +372,12 @@ private fun SMPProxyModePicker( smpProxyMode, values, icon = painterResource(MR.images.ic_settings_ethernet), + uiKey = Ui.AdvancedNetwork.ProxyMode, onSelected = { showModal { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.network_smp_proxy_mode_private_routing)) - SectionViewSelectableCards(null, smpProxyMode, values, updateSMPProxyMode) + SectionViewSelectableCards(null, smpProxyMode, values, Ui.AdvancedNetwork.ProxyMode, updateSMPProxyMode) } } } @@ -404,11 +408,12 @@ private fun SMPProxyFallbackPicker( values, icon = painterResource(MR.images.ic_arrows_left_right), enabled = enabled, + uiKey = Ui.AdvancedNetwork.ProxyFallback, onSelected = { showModal { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.network_smp_proxy_fallback_allow_downgrade)) - SectionViewSelectableCards(null, smpProxyFallback, values, updateSMPProxyFallback) + SectionViewSelectableCards(null, smpProxyFallback, values, Ui.AdvancedNetwork.ProxyFallback, updateSMPProxyFallback) } } } @@ -428,12 +433,13 @@ fun EnableKeepAliveSwitch( DefaultSwitch( checked = networkEnableKeepAlive.value, onCheckedChange = { networkEnableKeepAlive.value = it }, + modifier = Modifier.ui(Ui.AdvancedNetwork.KeepAlive), ) } } @Composable -fun IntSettingRow(title: String, selection: MutableState, values: List, label: String) { +fun IntSettingRow(title: String, selection: MutableState, values: List, label: String, uiKey: UiKey? = null) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -447,7 +453,8 @@ fun IntSettingRow(title: String, selection: MutableState, values: List expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value - } + }, + modifier = Modifier.ui(uiKey) ) { Row( Modifier.width(140.dp), @@ -477,6 +484,7 @@ fun IntSettingRow(title: String, selection: MutableState, values: List selection.value = selectionOption expanded.value = false }, + modifier = Modifier.ui(uiKey?.option(selectionOption)), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f) ) { Text( @@ -492,7 +500,7 @@ fun IntSettingRow(title: String, selection: MutableState, values: List } @Composable -fun TimeoutSettingRow(title: String, selection: MutableState, values: List, label: String) { +fun TimeoutSettingRow(title: String, selection: MutableState, values: List, label: String, uiKey: UiKey? = null) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -506,7 +514,8 @@ fun TimeoutSettingRow(title: String, selection: MutableState, values: List expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value - } + }, + modifier = Modifier.ui(uiKey) ) { val df = DecimalFormat("#.###") Row( @@ -539,6 +548,7 @@ fun TimeoutSettingRow(title: String, selection: MutableState, values: List selection.value = selectionOption expanded.value = false }, + modifier = Modifier.ui(uiKey?.option(selectionOption)), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f) ) { Text( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 8c9baf3fec..96bbf418b1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -39,6 +39,7 @@ import chat.simplex.common.model.ChatController.setUserServers import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -245,7 +246,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { rhId = currentRemoteHost?.remoteHostId ) } - }) { + }, uiKey = Ui.Network.YourServers) { Icon( painterResource(MR.images.ic_dns), stringResource(MR.strings.your_servers), @@ -263,8 +264,8 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { if (currentRemoteHost == null) { UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) - SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) } }) - SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } }) + SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) } }, uiKey = Ui.Network.SocksSettings) + SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } }, uiKey = Ui.Network.Advanced) } } if (currentRemoteHost == null && networkUseSocksProxy.value) { @@ -277,6 +278,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { SectionItemView( { scope.launch { saveServers(rhId = currentRemoteHost?.remoteHostId, currUserServers, userServers) } }, disabled = saveDisabled, + uiKey = Ui.Network.SaveServers, ) { Text(stringResource(MR.strings.smp_servers_save), color = if (!saveDisabled) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) } @@ -359,6 +361,7 @@ fun UseSocksProxySwitch( DefaultSwitch( checked = networkUseSocksProxy.value, onCheckedChange = toggleSocksProxy, + modifier = Modifier.ui(Ui.Network.UseSocks), ) } } @@ -479,7 +482,7 @@ fun SocksProxySettings( DefaultConfigurableTextField( hostUnsaved, stringResource(MR.strings.host_verb), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().ui(Ui.Socks.Host), isValid = ::validHost, keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }), keyboardType = KeyboardType.Text, @@ -487,14 +490,14 @@ fun SocksProxySettings( DefaultConfigurableTextField( portUnsaved, stringResource(MR.strings.port_verb), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().ui(Ui.Socks.Port), isValid = ::validPort, keyboardActions = KeyboardActions(onDone = { defaultKeyboardAction(ImeAction.Done); save(false) }), keyboardType = KeyboardType.Number, ) } - UseOnionHosts(onionHosts, rememberUpdatedState(networkUseSocksProxy && proxyAuthRandomUnsaved.value)) { + UseOnionHosts(onionHosts, rememberUpdatedState(networkUseSocksProxy && proxyAuthRandomUnsaved.value), Ui.Socks.OnionHosts) { onionHosts.value = it } } @@ -541,12 +544,13 @@ fun SocksProxySettings( passwordUnsaved.value = TextFieldValue() proxyAuthRandomUnsaved.value = true onionHosts.value = NetCfg.defaults.onionHosts - }, disabled = resetDisabled) { + }, disabled = resetDisabled, uiKey = Ui.Socks.Reset) { Text(stringResource(MR.strings.network_options_reset_to_defaults), color = if (resetDisabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } SectionItemView( click = { if (networkUseSocksProxy && !migration) showUpdateNetworkSettingsDialog { save(false) } else save(false) }, - disabled = saveDisabled + disabled = saveDisabled, + uiKey = Ui.Socks.Save ) { Text(stringResource(if (networkUseSocksProxy && !migration) MR.strings.network_options_save_and_reconnect else MR.strings.network_options_save), color = if (saveDisabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary) } @@ -576,6 +580,7 @@ private fun showUnsavedSocksHostPortAlert(confirmText: String, save: () -> Unit, fun UseOnionHosts( onionHosts: MutableState, enabled: State, + uiKey: UiKey? = null, useOnion: (OnionHosts) -> Unit, ) { val values = remember { @@ -596,6 +601,7 @@ fun UseOnionHosts( onionHosts, icon = painterResource(MR.images.ic_security), enabled = enabled, + uiKey = uiKey, onSelected = useOnion ) } else { @@ -606,6 +612,7 @@ fun UseOnionHosts( remember { mutableStateOf(OnionHosts.NEVER) }, icon = painterResource(MR.images.ic_security), enabled = enabled, + uiKey = uiKey, onSelected = {} ) } @@ -642,11 +649,12 @@ fun SessionModePicker( sessionMode, values, icon = painterResource(MR.images.ic_safety_divider), + uiKey = Ui.AdvancedNetwork.SessionMode, onSelected = { showModal { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.network_session_mode_transport_isolation)) - SectionViewSelectable(null, sessionMode, values, updateSessionMode) + SectionViewSelectable(null, sessionMode, values, Ui.AdvancedNetwork.SessionMode, onSelected = updateSessionMode) } } } @@ -694,7 +702,8 @@ private fun ServerOperatorRow( rhId ) } - } + }, + uiKey = Ui.Network.Operator(operator.operatorId) ) { Image( painterResource(operator.logo), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index 20a182551c..601513fd7f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -27,7 +27,9 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.getUsageConditions import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.ui import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* @@ -187,7 +189,7 @@ fun OperatorViewLayout( Column { SectionView(generalGetString(MR.strings.operator)) { - SectionItemView({ ModalManager.start.showModalCloseable { _ -> OperatorInfoView(operator) } }) { + SectionItemView({ ModalManager.start.showModalCloseable { _ -> OperatorInfoView(operator) } }, uiKey = Ui.Operator.Info) { Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -597,6 +599,7 @@ private fun UseOperatorToggle( changeOperatorEnabled(userServers, operatorIndex, false) } }, + modifier = Modifier.ui(Ui.Operator.Use), ) } } @@ -639,6 +642,7 @@ private fun SingleOperatorUsageConditionsView( Column(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING * 2), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingActionButton( + modifier = Modifier.ui(Ui.Operator.AcceptConditions), labelId = MR.strings.accept_conditions, onboarding = null, enabled = operatorIds.isNotEmpty(), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index c1c423e981..f158824c2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -21,7 +21,9 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.model.ChatModel @@ -180,7 +182,7 @@ fun CustomServer( val testedPreviously = remember { mutableMapOf() } TextEditor( serverAddress, - Modifier.height(144.dp) + Modifier.height(144.dp).ui(Ui.Servers.Address) ) LaunchedEffect(Unit) { snapshotFlow { serverAddress.value } @@ -250,7 +252,7 @@ private fun UseServerSection( onDelete: (() -> Unit)? = null, ) { SectionView(stringResource(MR.strings.smp_servers_use_server)) { - SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) { + SectionItemViewSpaceBetween(testServer, disabled = !valid || testing, uiKey = Ui.Servers.Test) { Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) ShowTestStatus(server.value) } @@ -259,13 +261,14 @@ private fun UseServerSection( PreferenceToggle( stringResource(MR.strings.smp_servers_use_server_for_new_conn), disabled = testing, - checked = enabled.value + checked = enabled.value, + uiKey = Ui.Servers.Use ) { server.value = server.value.copy(enabled = it) } if (onDelete != null) { - SectionItemView(onDelete, disabled = testing) { + SectionItemView(onDelete, disabled = testing, uiKey = Ui.Servers.Delete) { Text(stringResource(MR.strings.smp_servers_delete_server), color = if (testing) MaterialTheme.colors.secondary else MaterialTheme.colors.error) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 6e1fad133a..6e312f08e0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.background import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource @@ -104,7 +105,7 @@ fun YourServersViewLayout( SectionView(generalGetString(MR.strings.message_servers)) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (server.deleted) return@forEachIndexed - SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }, uiKey = Ui.Servers.Server(server.server)) { ProtocolServerViewLink( srv = server, serverProtocol = ServerProtocol.SMP, @@ -138,7 +139,7 @@ fun YourServersViewLayout( SectionView(generalGetString(MR.strings.media_and_file_servers)) { userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> if (server.deleted) return@forEachIndexed - SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { + SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }, uiKey = Ui.Servers.Server(server.server)) { ProtocolServerViewLink( srv = server, serverProtocol = ServerProtocol.XFTP, @@ -182,7 +183,8 @@ fun YourServersViewLayout( click = { showAddServerDialog(scope, userServers, serverErrors, serverWarnings, rhId) }, disabled = testing.value, textColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, - iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + uiKey = Ui.Servers.Add ) } globalServersErrors(serverErrors.value).forEach { err -> @@ -253,7 +255,8 @@ fun TestServersButton( testServers(testing, smpServers, xftpServers, chatRelays, chatModel, onUpdate, onUpdateRelays) } }, - disabled = disabled.value + disabled = disabled.value, + uiKey = Ui.Servers.TestAll ) { Text(stringResource(MR.strings.smp_servers_test_servers), color = if (!disabled.value) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) } @@ -275,7 +278,7 @@ fun showAddServerDialog( ModalManager.start.showCustomModal { close -> NewServerView(userServers, serverErrors, serverWarnings, rhId, close) } - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "enterManually")) { Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } if (appPlatform.isAndroid) { diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index cd80492607..deb2ea2d5d 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -475,6 +475,7 @@ edited sent + received unauthorized send send failed unread diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index b2e6fc9b21..7d7de8b730 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.* import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.ui import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -56,6 +56,7 @@ actual fun PlatformTextField( onUpArrow: () -> Unit, onFilesPasted: (List) -> Unit, focusRequester: FocusRequester?, + uiKey: UiKey?, onDone: () -> Unit, ) { @@ -118,7 +119,7 @@ actual fun PlatformTextField( autoCorrectEnabled = true ), modifier = Modifier - .ui(Ui.Chat.Message) + .ui(uiKey) .padding(start = startPadding, end = endPadding) .offset(y = (-5).dp) .fillMaxWidth() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index 997100246c..cbc4b47851 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.views.helpers.* import chat.simplex.res.* @@ -32,7 +33,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) { @Composable actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState) { - ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = { + ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), uiKey = Ui.Chat.ItemMenu(cItem.id, "save"), onClick = { val saveIfExists = { when (cItem.content.msgContent) { is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withLongRunningApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt index 2d5f58a545..ffcbe356d9 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.desktop.kt @@ -12,7 +12,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import chat.simplex.common.ui.UiKey import chat.simplex.common.ui.theme.WarningOrange +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @@ -26,6 +28,7 @@ actual fun SavePassphraseSetting( minHeight: Dp, enabled: Boolean, smallPadding: Boolean, + uiKey: UiKey?, onCheckedChange: (Boolean) -> Unit, ) { SectionItemView(minHeight = minHeight) { @@ -48,6 +51,7 @@ actual fun SavePassphraseSetting( DefaultSwitch( checked = useKeychain, onCheckedChange = onCheckedChange, + modifier = Modifier.ui(uiKey), enabled = enabled ) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt index 4ff8c552c4..2ecb06a68b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/ChooseAttachmentView.desktop.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.views.newchat.ActionButton import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @@ -11,15 +13,15 @@ import dev.icerock.moko.resources.compose.stringResource @Composable actual fun ChooseAttachmentButtons(attachmentOption: MutableState, hide: () -> Unit) { - ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) { + ActionButton(Modifier.fillMaxWidth(0.33f).ui(Ui.Chat.Attach("image")), null, stringResource(MR.strings.gallery_image_button), icon = painterResource(MR.images.ic_add_photo)) { attachmentOption.value = AttachmentOption.GalleryImage hide() } - ActionButton(Modifier.fillMaxWidth(0.5f), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) { + ActionButton(Modifier.fillMaxWidth(0.5f).ui(Ui.Chat.Attach("video")), null, stringResource(MR.strings.gallery_video_button), icon = painterResource(MR.images.ic_smart_display)) { attachmentOption.value = AttachmentOption.GalleryVideo hide() } - ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) { + ActionButton(Modifier.fillMaxWidth(1f).ui(Ui.Chat.Attach("file")), null, stringResource(MR.strings.choose_file), icon = painterResource(MR.images.ic_note_add)) { attachmentOption.value = AttachmentOption.File hide() } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.desktop.kt index f1103bc516..05685ac8ab 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.desktop.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import chat.simplex.common.model.CustomTimeUnit +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.DEFAULT_PADDING @Composable @@ -64,6 +65,7 @@ actual fun CustomTimePicker( selection, textColor = MaterialTheme.colors.onBackground, enabled = remember { mutableStateOf(true) }, + uiKey = Ui.TimePicker.Value, onSelected = { selection.value = it } ) Spacer(Modifier.width(DEFAULT_PADDING)) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt index aa70b3450f..c20f870a2d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt @@ -63,7 +63,7 @@ fun AppearanceScope.AppearanceLayout( } } } - SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI) + SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI, uiKey = Ui.Appearance.OneHandUI) if (remember { appPrefs.oneHandUI.state }.value && !remember { appPrefs.chatBottomBar.state }.value) { SettingsPreferenceItem(icon = null, stringResource(MR.strings.chat_bottom_bar), ChatModel.controller.appPrefs.chatBottomBar) } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt index 8ea4a3c5e7..b27cb0af54 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.desktop.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.views.helpers.* import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @@ -16,7 +17,7 @@ actual fun AdvancedSettingsAppSection( withAuth: (title: String, desc: String, block: () -> Unit) -> Unit, ) { SectionView { - SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) }) + SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) }, uiKey = Ui.Settings.DeveloperTools) val selectedChannel = remember { appPrefs.appUpdateChannel.state } val values = AppUpdatesChannel.entries.map { it to it.text } ExposedDropDownSettingRow(stringResource(MR.strings.app_check_for_updates), values, selectedChannel) { diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/AppearanceDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/AppearanceDesktopTest.kt new file mode 100644 index 0000000000..b97823dc80 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/AppearanceDesktopTest.kt @@ -0,0 +1,45 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.App +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.closeAppearance +import chat.simplex.common.ui.Ui +import org.junit.Test +import java.util.Locale + +private const val APP_LANGUAGE = "AppLanguage" + +class AppearanceDesktopTest : UiTest() { + @Test fun appearance_language_switch() = uiTest { + val alice = app("alice") + check(storedSetting(APP_LANGUAGE) == null && Locale.getDefault().language == "en") { "the app language before the change: ${storedSetting(APP_LANGUAGE)}, ${Locale.getDefault()}" } + selectLanguage(alice, "de") + poll(what = "German stored as the app language and applied") { storedSetting(APP_LANGUAGE) == "de" && Locale.getDefault().language == "de" } + closeAppearance() + assertSettingsStrings(alice, "Einstellungen", "Darstellung") + selectLanguageFromOpenSettings("system") + poll(what = "the app language cleared and the system language applied") { storedSetting(APP_LANGUAGE) == null && Locale.getDefault().language == "en" } + closeAppearance() + assertSettingsStrings(alice, "Settings", "Appearance") + } + + private fun UiScope.selectLanguage(app: App, tag: String) { + app.appearance() + click(Ui.Appearance.Language) + click(Ui.Appearance.Language.option(tag)) + } + + private fun UiScope.selectLanguageFromOpenSettings(tag: String) { + click(Ui.Settings.Appearance) + click(Ui.Appearance.Language) + click(Ui.Appearance.Language.option(tag)) + } + + private fun UiScope.assertSettingsStrings(app: App, settings: String, appearance: String) { + app.userPicker() + poll(what = "the user picker's settings row reading `$settings`") { textOf(Ui.UserPicker.Settings) == settings } + click(Ui.UserPicker.Settings) + poll(what = "the Appearance row reading `$appearance`") { exists(Ui.Settings.Appearance) && textOf(Ui.Settings.Appearance) == appearance } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ChatDataDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ChatDataDesktopTest.kt new file mode 100644 index 0000000000..7a43cfa42f --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ChatDataDesktopTest.kt @@ -0,0 +1,52 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.chatData +import chat.simplex.app.ui.harness.chatRunningInCore +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.DBMigrationResult +import org.junit.Test + +class ChatDataDesktopTest : UiTest() { + @Test fun dbMgmt_stoppedStatePersistsRestart() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + chatData(alice).management() + click(Ui.Database.RunChat) + poll(what = "the Stop chat? alert with Stop") { seesText("Stop chat?") && "Stop" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the chat stopped on screen, on disk and in the core") { seesText("Chat is stopped") && storedSetting("ChatStopped") == "true" && !chatRunningInCore() } + bob sends "@alice $WHILE_STOPPED" + restart(ready = { startChatAsked() }, what = "the Start chat? alert") + poll(what = "the Start chat? alert") { seesText("Start chat?") && seesText(START_CHAT_TEXT) } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the start to finish with the chat stopped") { + !AlertManager.shared.hasAlertsShown() && !chatModel.ctrlInitInProgress.value && chatModel.chatRunning.value == false + } + check(!chatRunningInCore() && storedSetting("ChatStopped") == "true") { "Cancel started the chat: ChatStopped=${storedSetting("ChatStopped")}" } + click(described("Settings")) + poll(what = "Chat data marked as stopped in settings") { "Chat is stopped" in descriptionsOf(Ui.Settings.Database) } + val current = restart(ready = { startChatAsked() }, what = "the Start chat? alert after the second restart") + poll(what = "the Start chat? alert after the second restart") { seesText("Start chat?") && "OK" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the chat running with bob's message sent while it was stopped") { + chatRunningInCore() && WHILE_STOPPED in current.chatList.rowTexts("bob") + } + check(storedSetting("ChatStopped") == "false") { "ChatStopped is still stored after the start" } + current.chatList.open("bob").send(STARTED_AGAIN) + bob.expectsMessage("alice> $STARTED_AGAIN", CONNECT_TIMEOUT_MS) + } + + private fun startChatAsked(): Boolean = chatModel.chatDbStatus.value == DBMigrationResult.OK && AlertManager.shared.hasAlertsShown() + + private companion object { + const val WHILE_STOPPED = "sent before the restart" + const val STARTED_AGAIN = "started again" + const val START_CHAT_TEXT = "Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ChatSearchDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ChatSearchDesktopTest.kt new file mode 100644 index 0000000000..db6c1bf51e --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ChatSearchDesktopTest.kt @@ -0,0 +1,32 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.contentFilter +import chat.simplex.app.ui.harness.pickContentFilter +import chat.simplex.common.ui.Ui +import org.junit.Test +import java.io.File + +class ChatSearchDesktopTest : UiTest() { + @KnownAppIssue("15.5 item NEW", "waiting for `Files` in the content filter menu after a file note was added") + @Test fun contentFilter_fileAddedInOpenChat_listsFiles() = uiTest { + val alice = app("alice") + val source = File(exchangeDir(), "list.txt").apply { writeText("apples") } + val notes = alice.chatList.open("Private notes") + click(Ui.Chat.ContentFilter) + poll(what = "`Images` and `Links` without `Files` in the content filter menu of the empty notes") { + exists(contentFilter("Images")) && exists(contentFilter("Links")) && !exists(contentFilter("Files")) + } + click(contentFilter("Links")) + waitFor(Ui.Chat.Bar.part("search")) + pickContentFilter("all") + poll(what = "the content filter off") { !exists(Ui.Chat.Bar.part("search")) } + enterText(Ui.Chat.Message, source.absolutePath) + poll(what = "the preview of list.txt") { seesDescription("Cancel file preview") && seesText("list.txt") } + click(Ui.Chat.Send) + notes.itemId("the note with list.txt") { it.file?.fileName == "list.txt" } + click(Ui.Chat.ContentFilter) + poll(what = "`Files` in the content filter menu after a file note was added") { exists(contentFilter("Files")) } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ComposeDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ComposeDesktopTest.kt new file mode 100644 index 0000000000..3ee5a25dbb --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ComposeDesktopTest.kt @@ -0,0 +1,142 @@ +package chat.simplex.app.ui.desktop + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.withKeyDown +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.MsgContent +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.ui.UiKey +import com.sun.net.httpserver.HttpServer +import org.junit.Test +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.Closeable +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import javax.imageio.ImageIO + +class ComposeDesktopTest : UiTest() { + @Test fun multiline_shiftEnter_peerReceivesTwoLines() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + typeInto(Ui.Chat.Message, " ") + pressEnter(Ui.Chat.Message) + check(editableText(Ui.Chat.Message) == " " && !isEnabled(Ui.Chat.Send)) { "Enter on a blank message changed the field to `${editableText(Ui.Chat.Message)}`" } + replaceText(Ui.Chat.Message, "hello") + pressShiftEnter(Ui.Chat.Message) + poll(what = "a new line after `hello` in the message field") { editableText(Ui.Chat.Message) == "hello\n" } + chat.send("there") + bob expectsMessage "alice> hello" + bob expects "there" + } + + @Test fun linkPreview_enable_peerReceivesLink_cancelSendsText() = uiTest { + PreviewServer().use { server -> + val alice = app("alice"); val bob = cli("bob") + connect(bob) + showLinkPreviewsAlertViaApi() + val chat = alice.chatList.open("bob") + val cat = server.url("cat.png") + typeInto(Ui.Chat.Message, cat) + poll(what = "the `Enable link previews?` alert with `Enable`") { seesText("Enable link previews?") && exists(ENABLE) } + check(server.requests().isEmpty()) { "the preview was fetched before it was enabled: ${server.requests()}" } + click(ENABLE) + poll(what = "the cat.png preview in the compose area") { seesText("cat.png") && exists(CANCEL_PREVIEW) && seesDescription("link preview image") } + check(server.requests() == listOf("/cat.png")) { "preview requests: ${server.requests()}" } + chat.sendComposed() + bob expectsMessage "alice> $cat" + val id = chat.itemId(cat) + poll(what = "alice's sent message showing the cat.png card") { + !exists(CANCEL_PREVIEW) && "cat.png" in textsOf(Ui.Chat.Item(id)) && "link preview image" in descriptionsOf(Ui.Chat.Item(id)) + } + val aliceId = bob.contactId("alice") + bob sends "/_get content types @$aliceId" + bob expects "Chat content types: link" + val dog = server.url("dog.png") + typeInto(Ui.Chat.Message, "see $dog") + poll(what = "the dog.png preview fetched without asking again") { seesText("dog.png") && exists(CANCEL_PREVIEW) } + check(!seesText("Enable link previews?") && server.requests() == listOf("/cat.png", "/dog.png")) { "alert shown again or preview requests: ${server.requests()}" } + click(CANCEL_PREVIEW) + poll(what = "the dog.png preview removed") { !seesText("dog.png") && !exists(CANCEL_PREVIEW) } + chat.sendComposed() + bob expectsMessage "alice> see $dog" + bob sends "/_get content types @$aliceId" + bob expects "Chat content types: link, text" + } + } + + @Test fun linkPreview_firstTimeAlert_disable_noPreview() = uiTest { + PreviewServer().use { server -> + val alice = app("alice") + showLinkPreviewsAlertViaApi() + val notes = alice.chatList.open("Private notes") + check(storedSetting(LINK_PREVIEWS) != "false") { "link previews are stored as off before the alert" } + val cat = server.url("cat.png") + typeInto(Ui.Chat.Message, cat) + poll(what = "the `Enable link previews?` alert with `Disable`") { seesText("Enable link previews?") && exists(DISABLE) } + click(DISABLE) + poll(what = "the alert closed and link previews stored as off") { + !seesText("Enable link previews?") && storedSetting(LINK_PREVIEWS) == "false" && storedSetting(LINK_PREVIEWS_ALERT) == "false" + } + awaitAppWork() + check(server.requests().isEmpty() && !exists(CANCEL_PREVIEW)) { "a preview after Disable; requests: ${server.requests()}" } + notes.sendComposed() + val id = notes.itemId(cat) + poll(what = "the note saved as text without a card") { + notes.item(id)?.content?.msgContent is MsgContent.MCText && "link preview image" !in descriptionsOf(Ui.Chat.Item(id)) + } + typeInto(Ui.Chat.Message, server.url("dog.png")) + awaitAppWork() + check(!seesText("Enable link previews?") && !exists(CANCEL_PREVIEW) && server.requests().isEmpty()) { "alert or preview for a link typed after Disable; requests: ${server.requests()}" } + } + } + + private fun showLinkPreviewsAlertViaApi() = appPrefs.privacyLinkPreviewsShowAlert.set(true) + + private fun UiScope.awaitAppWork() = poll(what = "the app to finish handling the typed link") { UiIdle.inFlight().isEmpty() } + + private fun UiScope.pressShiftEnter(key: UiKey) { + val node = ui.onNode(matcher(key)) + ui.runOnUiThread { node.performSemanticsAction(SemanticsActions.RequestFocus) } + ui.runOnUiThread { node.performKeyInput { withKeyDown(Key.ShiftLeft) { pressKey(Key.Enter) } } } + ui.waitForIdle() + } + + private class PreviewServer : Closeable { + private val paths = CopyOnWriteArrayList() + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + createContext("/") { exchange -> + paths += exchange.requestURI.path + exchange.responseHeaders.add("Content-Type", "image/png") + exchange.sendResponseHeaders(200, PNG.size.toLong()) + exchange.responseBody.use { it.write(PNG) } + } + start() + } + + fun url(name: String) = "http://127.0.0.1:${server.address.port}/$name" + + fun requests(): List = paths.toList() + + override fun close() = server.stop(0) + } + + private companion object { + val ENABLE = Ui.Alert.Option(Ui.AlertSource.Shared, "enable") + val DISABLE = Ui.Alert.Option(Ui.AlertSource.Shared, "disable") + val CANCEL_PREVIEW = described("cancel link preview") + const val LINK_PREVIEWS = "PrivacyLinkPreviews" + const val LINK_PREVIEWS_ALERT = "PrivacyLinkPreviewsShowAlert" + val PNG: ByteArray = ByteArrayOutputStream().also { ImageIO.write(BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB), "png", it) }.toByteArray() + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/CopyShareDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/CopyShareDesktopTest.kt new file mode 100644 index 0000000000..83672bc5e1 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/CopyShareDesktopTest.kt @@ -0,0 +1,69 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.itemFileOf +import chat.simplex.app.ui.harness.sendsFileTo +import chat.simplex.common.model.CIFileStatus +import chat.simplex.common.simplexWindowState +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class CopyShareDesktopTest : UiTest() { + @Test fun copy_text_toClipboardAndToast() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hello there" + bob sends "@alice second line" + val chat = alice.chatList.open("bob") + chat.itemId("hello there") + chat.itemId("second line") + val before = copiedToasts() + setClipboardText("unrelated") + chat.menu("hello there", "copy") + poll(what = "`hello there` on the clipboard and the `Copied to clipboard` toast") { clipboardText() == "hello there" && copiedToasts() == before + 1 } + setClipboardText("unrelated") + chat.menu("second line", "share") + poll(what = "`second line` on the clipboard from Share and a second toast") { clipboardText() == "second line" && copiedToasts() == before + 2 } + } + + @Test fun copy_fileItem_copiesPath() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob.sendsFileTo("alice", bob.createFile(NOTES_NAME, NOTES), 1, NOTES_NAME) + val chat = alice.chatList.open("bob") + val id = chat.itemId("the item of $NOTES_NAME") { it.file?.fileName == NOTES_NAME } + rightClick(Ui.Chat.Item(id)) + click(Ui.Chat.ItemMenu(id, "download")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "$NOTES_NAME received") { itemFileOf(id)?.fileStatus is CIFileStatus.RcvComplete } + check(itemFileOf(id)?.fileSource?.cryptoArgs != null) { "$NOTES_NAME is stored unencrypted" } + val copied = copiedToasts() + setClipboardText(null) + rightClick(Ui.Chat.Item(id)) + click(Ui.Chat.ItemMenu(id, "copy")) + poll(what = "the path of a readable copy of $NOTES_NAME on the clipboard and the `Copied to clipboard` toast") { + clipboardText()?.let { File(it).isFile && File(it).readText() == NOTES } == true && copiedToasts() == copied + 1 + } + val target = File(exchangeDir(), "shared-$NOTES_NAME") + val saved = toasts().count { it == "File saved" } + check(!target.exists()) { "${target.name} exists before Share" } + rightClick(Ui.Chat.Item(id)) + saveFileAs(target) { click(Ui.Chat.ItemMenu(id, "share")) } + poll(what = "the decrypted $NOTES_NAME written to ${target.name} and the `File saved` toast") { + target.length() == NOTES.length.toLong() && toasts().count { it == "File saved" } == saved + 1 + } + assertEquals(NOTES, target.readText()) + } + + private fun toasts(): List = simplexWindowState.toasts.toList().map { it.first } + + private fun copiedToasts(): Int = toasts().count { it == "Copied to clipboard" } + + private companion object { + const val NOTES_NAME = "copy-notes.md" + const val NOTES = "Shopping list from bob: apples and pears" + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/DraftDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/DraftDesktopTest.kt new file mode 100644 index 0000000000..8ae393d27f --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/DraftDesktopTest.kt @@ -0,0 +1,35 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test +import java.io.File +import kotlin.io.path.createTempDirectory + +class DraftDesktopTest : UiTest() { + @Test fun draft_withAttachment_restored() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val source = File(createTempDirectory("uitest-draft").toFile(), "report.txt").apply { writeText(REPORT) } + val bobId = alice.chatList.chatId("bob"); val notesId = alice.chatList.chatId("Private notes") + alice.chatList.open("bob") + waitFor(Ui.Chat.Message) + check(!seesDescription("Cancel file preview")) { "a file preview is shown before the paste" } + enterText(Ui.Chat.Message, source.absolutePath) + poll(what = "the preview of report.txt") { seesDescription("Cancel file preview") && seesText("report.txt") } + alice.chatList.open("Private notes") + poll(what = "notes without the preview") { chatModel.chatId.value == notesId && exists(Ui.Chat.Message) && !seesDescription("Cancel file preview") } + alice.chatList.open("bob") + poll(what = "the preview restored") { chatModel.chatId.value == bobId && seesDescription("Cancel file preview") && seesText("report.txt") } + click(Ui.Chat.Send) + bob.eventuallyMessage("alice> sends file report.txt (${REPORT.length} bytes / ${REPORT.length} bytes)", CONNECT_TIMEOUT_MS) + bob expects "use /fr 1 [/ | ] to receive it" + } + + private companion object { + const val REPORT = "Quarterly report kept in a draft" + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/EditMessageDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/EditMessageDesktopTest.kt new file mode 100644 index 0000000000..e79fa18602 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/EditMessageDesktopTest.kt @@ -0,0 +1,98 @@ +package chat.simplex.app.ui.desktop + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.pressKey +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.common.model.MsgContent +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import kotlin.io.path.createTempDirectory + +class EditMessageDesktopTest : UiTest() { + @Test fun edit_upArrow_editsLastMessage() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("first") + bob expectsMessage "alice> first" + chat.send("second") + bob expectsMessage "alice> second" + bob sends "@alice from bob" + chat.itemId("from bob") + check(editableText(Ui.Chat.Message).isNullOrEmpty() && !exists(described("Cancel"))) { "the message field is not empty or already editing before Up" } + pressUp(Ui.Chat.Message) + poll(what = "the edit bar with alice's last message `second`") { editableText(Ui.Chat.Message) == "second" && exists(described("Cancel")) } + chat.replaceAndSend("second!") + bob expectsMessage "alice> [edited] second!" + typeInto(Ui.Chat.Message, "x") + pressUp(Ui.Chat.Message) + assertTrue("Up with text in the field started editing", editableText(Ui.Chat.Message) == "x" && !exists(described("Cancel"))) + } + + @Test fun edit_ownFile_keepsFileWithoutPreview() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val dir = createTempDirectory("uitest-edit-file").toFile() + val source = File(dir, "report.txt").apply { writeText(REPORT) } + val chat = alice.chatList.open("bob") + waitFor(Ui.Chat.Message) + enterText(Ui.Chat.Message, source.absolutePath) + poll(what = "the report.txt preview with `Cancel file preview`") { + seesDescription("Cancel file preview") && seesText("report.txt") && editableText(Ui.Chat.Message).isNullOrEmpty() + } + typeInto(Ui.Chat.Message, "doc") + click(Ui.Chat.Send) + bob.eventuallyMessage("alice> doc", CONNECT_TIMEOUT_MS) + bob expectsMessage "alice> sends file report.txt (${REPORT.length} bytes / ${REPORT.length} bytes)" + bob expects "use /fr 1 [/ | ] to receive it" + val id = fileItemId("report.txt") + poll(what = "the sent preview cleared and report.txt shown in the item") { + !seesDescription("Cancel file preview") && "report.txt" in textsOf(Ui.Chat.Item(id)) + } + val shown = texts().count { it == "report.txt" } + rightClick(Ui.Chat.Item(id)) + click(Ui.Chat.ItemMenu(id, "edit")) + poll(what = "the edit bar with `doc`, no compose file preview and no `Cancel file preview`") { + editableText(Ui.Chat.Message) == "doc" && exists(described("Cancel")) && texts().count { it == "report.txt" } == shown && !seesDescription("Cancel file preview") + } + chat.replaceAndSend("new doc") + bob expectsMessage "alice> [edited] new doc" + poll(what = "the edited item `new doc` still a file item showing report.txt") { + val item = chatModel.chatsContext.chatItems.value.firstOrNull { it.id == id } + item != null && item.content.msgContent is MsgContent.MCFile && item.content.text == "new doc" && "report.txt" in textsOf(Ui.Chat.Item(id)) + } + chat.send("ping") + bob expectsMessage "alice> ping" + } + + private fun UiScope.pressUp(key: UiKey) { + val node = ui.onNode(matcher(key)) + ui.runOnUiThread { node.performSemanticsAction(SemanticsActions.RequestFocus) } + ui.runOnUiThread { node.performKeyInput { pressKey(Key.DirectionUp) } } + ui.waitForIdle() + } + + private fun UiScope.fileItemId(name: String): Long { + var id: Long? = null + poll(what = "the item of file `$name`") { + id = chatModel.chatsContext.chatItems.value.lastOrNull { it.content.msgContent is MsgContent.MCFile && it.file?.fileName == name }?.id + id?.let { exists(Ui.Chat.Item(it)) } == true + } + return id!! + } + + private companion object { + const val REPORT = "Quarterly report edited by Bob." + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/HelpDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/HelpDesktopTest.kt new file mode 100644 index 0000000000..1c66340076 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/HelpDesktopTest.kt @@ -0,0 +1,42 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.helpAndSupport +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import org.junit.Test + +private const val MAILTO = "mailto:chat@simplex.chat" +private const val CONTRIBUTE = "https://github.com/simplex-chat/simplex-chat#contribute" +private const val STAR = "https://github.com/simplex-chat/simplex-chat" + +class HelpDesktopTest : UiTest() { + @Test fun help_contributeAndStar() = uiTest { + val alice = app("alice") + helpAndSupport(alice) + check(opened().isEmpty()) { "links opened before any tap: ${opened()}" } + click(Ui.Help.Email) + poll(what = "the mailto link opened without a confirmation") { opened() == listOf(MAILTO) && !AlertManager.shared.hasAlertsShown() } + click(Ui.Help.Contribute) + awaitExternalLinkAlert(CONTRIBUTE) + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the alert closed by Cancel") { !AlertManager.shared.hasAlertsShown() } + check(opened() == listOf(MAILTO)) { "Cancel opened the link: ${opened()}" } + click(Ui.Help.Contribute) + awaitExternalLinkAlert(CONTRIBUTE) + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the Contribute link opened after Open") { opened() == listOf(MAILTO, CONTRIBUTE) && !AlertManager.shared.hasAlertsShown() } + click(Ui.Help.Star) + awaitExternalLinkAlert(STAR) + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the Star on GitHub link opened after Open") { opened() == listOf(MAILTO, CONTRIBUTE, STAR) } + } + + private fun UiScope.opened(): List = synchronized(uriHandler.opened) { uriHandler.opened.toList() } + + private fun UiScope.awaitExternalLinkAlert(url: String) = + poll(what = "the Open external link? alert for $url with Open") { + seesText("Open external link?") && seesText(url) && "Open" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ItemInfoDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ItemInfoDesktopTest.kt new file mode 100644 index 0000000000..91a32db3c0 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ItemInfoDesktopTest.kt @@ -0,0 +1,36 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.common.model.localTimestamp +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ItemInfoDesktopTest : UiTest() { + @Test fun info_share_copiesText() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice question" + val chat = alice.chatList.open("bob") + chat.itemId("question") + chat.menu("question", "reply") + chat.send("answer") + bob expectsMessage "alice> > question" + bob expects " answer" + val id = chat.itemId("answer") + chat.itemInfo("answer") + setClipboardText("unrelated") + click(Ui.ItemInfo.Share) + var shared = "" + poll(what = "the item info text on the clipboard after Share") { shared = clipboardText().orEmpty(); shared.startsWith("# Sent message") } + val sentAt = localTimestamp(chat.item(id)!!.meta.itemTs) + check(shared.startsWith("# Sent message\n\nSent at: $sentAt\n")) { "the shared info does not start with the title and `Sent at: $sentAt`: `$shared`" } + val lines = shared.lines() + check("## In reply to" in lines && "question" in lines) { "the shared info has no `In reply to` section with bob's `question`: `$shared`" } + check("## History" in lines && shared.endsWith(" (current)\nanswer")) { "the shared info has no history ending with the current `answer`: `$shared`" } + val notesId = alice.chatList.chatId("Private notes") + click(Ui.ChatList.Row(notesId)) + poll(what = "the notes open and the item info closed") { chatModel.chatId.value == notesId && !exists(Ui.ItemInfo.Share) && !seesText("Sent message") } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/LockDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/LockDesktopTest.kt new file mode 100644 index 0000000000..c62a2117d7 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/LockDesktopTest.kt @@ -0,0 +1,525 @@ +package chat.simplex.app.ui.desktop + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.withKeyDown +import chat.simplex.app.ui.harness.ChatDataScreen +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.LockScreen +import chat.simplex.app.ui.harness.LockScreen.Companion.CONFIRM_PASSCODE +import chat.simplex.app.ui.harness.LockScreen.Companion.ENTER_PASSCODE +import chat.simplex.app.ui.harness.LockScreen.Companion.NEW_PASSCODE +import chat.simplex.app.ui.harness.LockScreen.Companion.PASSCODE_SET +import chat.simplex.app.ui.harness.PasscodeScreen +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.chatRunningInCore +import chat.simplex.app.ui.harness.closeDeveloperTools +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.developerTools +import chat.simplex.app.ui.harness.newGroupsViaApi +import chat.simplex.app.ui.harness.openProfileMenu +import chat.simplex.app.ui.harness.userIdOf +import chat.simplex.app.ui.harness.userShowsNtfs +import chat.simplex.app.ui.harness.yourPrivacy +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.AppLock +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Base64 + +class LockDesktopTest : UiTest() { + @Test fun lock_enable_setsPasscode() = uiTest { + val alice = app("alice") + val privacy = yourPrivacy(alice) + check("Off" in textsOf(Ui.Privacy.Lock)) { "SimpleX Lock before enabling: ${textsOf(Ui.Privacy.Lock)}" } + val lock = privacy.lock() + check(!exists(Ui.Lock.Delay) && !exists(Ui.Lock.ChangePasscode) && !exists(Ui.Lock.SelfDestruct)) { "lock options shown while the lock is off" } + check(storedSetting(PERFORM_LA) != "true" && storedSetting(APP_PASSCODE) == null) { "the lock is stored as on before enabling" } + click(Ui.Lock.Enable) + lock.passcode.await(NEW_PASSCODE) + lock.passcode.enter(CODE) + click(Ui.Passcode.Cancel) + poll(what = "the New Passcode screen to close") { lock.passcode.gone() && exists(Ui.Lock.Enable) } + check(storedSetting(PERFORM_LA) != "true" && storedSetting(APP_PASSCODE) == null && !exists(Ui.Lock.Delay)) { "Cancel turned the lock on" } + click(Ui.Lock.Enable) + lock.passcode.await(NEW_PASSCODE) + lock.passcode.enter(CODE) + lock.passcode.submit() + lock.passcode.await(CONFIRM_PASSCODE) + assertEquals("Confirm", lock.passcode.submitLabel()) + lock.passcode.enter(MISMATCH) + lock.passcode.submit() + check(seesText(CONFIRM_PASSCODE) && !AlertManager.shared.hasAlertsShown() && storedSetting(APP_PASSCODE) == null) { "a mismatched confirmation was accepted" } + lock.passcode.backspace() + lock.passcode.backspace() + lock.passcode.enter(CODE.takeLast(2)) + lock.passcode.submit() + lock.passcode.confirmAlert(PASSCODE_SET) + poll(what = "the lock stored as on with the passcode") { storedSetting(PERFORM_LA) == "true" && storedSetting(LOCK_DELAY) == "30" && decoded(APP_PASSCODE) == CODE } + check(textOf(Ui.Lock.Delay) == "30 seconds" && exists(Ui.Lock.ChangePasscode) && exists(Ui.Lock.SelfDestruct)) { "lock options after enabling: ${texts().take(30)}" } + escape() + poll(what = "Your privacy showing SimpleX Lock as Passcode") { !exists(Ui.Lock.Enable) && "Passcode" in textsOf(Ui.Privacy.Lock) } + } + + @Test fun lock_screenOnStartup_wrongThenCorrect() = uiTest { + val alice = app("alice") + yourPrivacy(alice).lock().apply { enable(CODE); close() } + val current = restart() + val passcode = PasscodeScreen(this) + passcode.await(ENTER_PASSCODE) + check(seesText("Unlock") && passcode.submitLabel() == "Submit" && AppLock.userAuthorized.value == false) { "the startup passcode screen: ${texts().take(30)}" } + passcode.enter(WRONG) + passcode.submit() + passcode.authFailed() + poll(what = "the Unlock button instead of the app") { exists(Ui.Lock.Unlock) && passcode.gone() } + check(AppLock.userAuthorized.value == false) { "a wrong passcode unlocked the app" } + click(Ui.Lock.Unlock) + passcode.await(ENTER_PASSCODE) + passcode.enter(CODE) + passcode.submit() + awaitUnlocked(passcode) + val notes = current.chatList.open(NOTES) + notes.send(NOTE) + notes.itemId(NOTE) + } + + @Test fun lock_changePasscode_newPasscodeUnlocks() = uiTest { + val alice = app("alice") + val lock = yourPrivacy(alice).lock() + lock.enable(CODE) + click(Ui.Lock.ChangePasscode) + lock.passcode.await(CURRENT_PASSCODE) + lock.passcode.enter(WRONG) + lock.passcode.submit() + lock.passcode.authFailed() + check(decoded(APP_PASSCODE) == CODE && !seesText(NEW_PASSCODE)) { "a wrong current passcode opened the change" } + click(Ui.Lock.ChangePasscode) + lock.passcode.await(CURRENT_PASSCODE) + lock.passcode.enter(CODE) + lock.passcode.submit() + lock.passcode.await(NEW_PASSCODE) + check(seesText("App passcode")) { "the New Passcode screen does not name the app passcode: ${texts().take(30)}" } + lock.passcode.enter(NEW_CODE) + lock.passcode.submit() + lock.passcode.await(CONFIRM_PASSCODE) + lock.passcode.enter(NEW_CODE) + lock.passcode.submit() + lock.passcode.confirmAlert("Passcode changed!") + poll(what = "the new passcode stored") { decoded(APP_PASSCODE) == NEW_CODE } + lock.close() + restart() + val passcode = PasscodeScreen(this) + passcode.await(ENTER_PASSCODE) + passcode.enter(NEW_CODE) + passcode.submit() + awaitUnlocked(passcode) + } + + @Test fun lock_disable_requiresPasscode() = uiTest { + val alice = app("alice") + val lock = yourPrivacy(alice).lock() + lock.enable(CODE) + click(Ui.Lock.Enable) + lock.passcode.await(ENTER_PASSCODE) + check(seesText("Disable SimpleX Lock")) { "the passcode prompt does not say it disables the lock: ${texts().take(30)}" } + lock.passcode.enter(WRONG) + lock.passcode.submit() + lock.passcode.authFailed() + check(storedSetting(PERFORM_LA) == "true" && decoded(APP_PASSCODE) == CODE && exists(Ui.Lock.ChangePasscode)) { "a wrong passcode turned the lock off" } + click(Ui.Lock.Enable) + lock.passcode.await(ENTER_PASSCODE) + lock.passcode.enter(CODE) + lock.passcode.submit() + poll(what = "the lock stored as off and its options hidden") { + lock.passcode.gone() && storedSetting(PERFORM_LA) == "false" && storedSetting(APP_PASSCODE) == null && !exists(Ui.Lock.ChangePasscode) && !exists(Ui.Lock.Delay) + } + lock.close() + val after = restart() + poll(what = "the app open without a passcode screen") { AppLock.userAuthorized.value == true && after.chatList.shown(NOTES) } + check(!seesText(ENTER_PASSCODE) && !exists(Ui.Passcode.Submit)) { "a passcode screen after the lock was turned off" } + } + + @Test fun lock_relocksAfterDelay() = uiTest { + val alice = app("alice") + val lock = yourPrivacy(alice).lock() + lock.enable(CODE) + lock.close() + hideAndReturn() + settle() + check(AppLock.userAuthorized.value == true && !exists(Ui.Passcode.Submit)) { "the app locked on return before 30 seconds passed" } + val again = yourPrivacy(alice).lock() + click(Ui.Lock.Delay) + for ((delay, name) in DELAYS) { + check(name in textsOf(Ui.Lock.Delay.option(delay))) { "Lock after option $delay is not `$name`: ${textsOf(Ui.Lock.Delay.option(delay))}" } + } + click(Ui.Lock.Delay.option(0)) + poll(what = "Immediately shown and stored") { textOf(Ui.Lock.Delay) == "Immediately" && storedSetting(LOCK_DELAY) == "0" } + again.close() + hideAndReturn() + val passcode = PasscodeScreen(this) + passcode.await(ENTER_PASSCODE) + check(AppLock.userAuthorized.value == false) { "the passcode screen shows but the app is authorized" } + passcode.enter(CODE) + passcode.submit() + awaitUnlocked(passcode) + } + + @Test fun selfDestruct_triggeredOnLockScreen() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice $BEFORE_WIPE" + poll(what = "bob's message on the row") { BEFORE_WIPE in alice.chatList.rowTexts("bob") } + val lock = yourPrivacy(alice).lock() + lock.enable(CODE) + check(storedSetting(SELF_DESTRUCT) != "true" && !exists(Ui.Lock.SelfDestructName)) { "self-destruct is on before enabling" } + click(Ui.Lock.SelfDestruct) + lock.passcode.await(CURRENT_PASSCODE) + lock.passcode.enter(CODE) + lock.passcode.submit() + lock.passcode.await(SET_PASSCODE) + check(seesText("Enable self-destruct passcode")) { "the Set passcode screen does not name self-destruct: ${texts().take(30)}" } + lock.passcode.enter(CODE) + lock.passcode.submit() + check(seesText(SET_PASSCODE) && !seesText(CONFIRM_PASSCODE) && lock.passcode.submitLabel() == "Save") { "the app passcode was accepted as the self-destruct passcode" } + lock.passcode.clear() + lock.passcode.enter(SD_CODE) + lock.passcode.submit() + lock.passcode.await(CONFIRM_PASSCODE) + lock.passcode.enter(SD_CODE) + lock.passcode.submit() + lock.passcode.confirmAlert("Self-destruct passcode enabled!") + poll(what = "self-destruct stored as on with its passcode") { storedSetting(SELF_DESTRUCT) == "true" && decoded(SD_PASSCODE) == SD_CODE && exists(Ui.Lock.SelfDestructName) } + typeInto(Ui.Lock.SelfDestructName, WIPED_NAME) + poll(what = "the new display name stored") { storedSetting(SD_NAME) == WIPED_NAME } + lock.close() + restart() + val passcode = PasscodeScreen(this) + passcode.await(ENTER_PASSCODE) + passcode.enter(SD_CODE) + passcode.submit() + poll(timeoutMs = 30_000, what = "the app opened as $WIPED_NAME without bob") { + AppLock.userAuthorized.value == true && chatModel.currentUser.value?.displayName == WIPED_NAME && chatModel.chats.value.none { it.chatInfo.chatViewName == "bob" } + } + assertEquals(listOf(WIPED_NAME), coreUserNames()) + check(decoded(APP_PASSCODE) == SD_CODE && storedSetting(SD_PASSCODE) == null && storedSetting(SELF_DESTRUCT) == "false" && storedSetting(SD_NAME) == null) { + "after the wipe: app passcode ${decoded(APP_PASSCODE)}, self-destruct passcode stored ${storedSetting(SD_PASSCODE) != null}, self-destruct ${storedSetting(SELF_DESTRUCT)}, name ${storedSetting(SD_NAME)}" + } + restart() + passcode.await(ENTER_PASSCODE) + passcode.enter(SD_CODE) + passcode.submit() + awaitUnlocked(passcode) + check(chatModel.currentUser.value?.displayName == WIPED_NAME && coreUserNames() == listOf(WIPED_NAME)) { "the self-destruct code wiped the data again: ${coreUserNames()}" } + } + + @Test fun lock_protectedActionsPrompt() = uiTest { + val alice = app("alice") + yourPrivacy(alice).lock().apply { enable(CODE); close() } + val passcode = PasscodeScreen(this) + developerTools(alice) + click(Ui.DevTools.Console) + passcode.await("Open chat console") + check(seesText(LOG_IN_REASON) && !exists(Ui.Chat.Message)) { "the console prompt: ${texts().take(30)}" } + passcode.enter(CODE) + passcode.submit() + poll(what = "the chat console after the passcode") { passcode.gone() && !exists(Ui.Lock.Unlock) && exists(Ui.Chat.Message) } + escape() + poll(what = "the chat console to close") { !exists(Ui.Chat.Message) && exists(Ui.DevTools.Console) } + closeDeveloperTools() + alice.settings() + click(Ui.Settings.Migrate) + passcode.await("Open migration screen") + passcode.enter(WRONG) + passcode.submit() + poll(what = "the passcode screen closed with only Unlock left") { passcode.gone() && exists(Ui.Lock.Unlock) } + check(!seesText(MIGRATE_TITLE)) { "a wrong passcode opened the migration screen" } + click(Ui.Lock.Unlock) + passcode.await("Open migration screen") + click(Ui.Passcode.Cancel) + poll(what = "the passcode screen cancelled with Unlock left") { passcode.gone() && exists(Ui.Lock.Unlock) } + check(!seesText(MIGRATE_TITLE)) { "cancelling the passcode opened the migration screen" } + escape() + poll(what = "Unlock closed back to Your settings") { !exists(Ui.Lock.Unlock) && exists(Ui.Settings.Database) } + check(!seesText(MIGRATE_TITLE)) { "closing Unlock opened the migration screen" } + click(Ui.Settings.Database) + ChatDataScreen(this).management() + stopChatAndConfirm() + passcode.await("Stop chat") + passcode.enter(WRONG) + passcode.submit() + passcode.authFailed() + check(chatRunningInCore() && chatModel.chatRunning.value == true && seesText("Chat is running")) { "a wrong passcode stopped the chat" } + stopChatAndConfirm() + passcode.await("Stop chat") + passcode.enter(CODE) + passcode.submit() + poll(what = "the chat stopped after the passcode") { passcode.gone() && seesText("Chat is stopped") && !chatRunningInCore() } + } + + @Test fun profiles_actionsRequireAuthOnceWhenLocked() = uiTest { + val alice = app("alice") + yourPrivacy(alice).lock().apply { enable(CODE); close() } + val aliceId = userIdOf("alice") + val passcode = PasscodeScreen(this) + val profiles = alice.userPicker().profiles() + waitFor(Ui.Profiles.Add) + check(!exists(Ui.Passcode.Submit) && !exists(Ui.Lock.Unlock)) { "opening Your chat profiles asked for the passcode" } + click(Ui.Profiles.Add) + passcode.await(CHANGE_PROFILES) + check(!exists(Ui.NewProfile.Name)) { "the new profile screen opened before the passcode" } + passcode.enter(CODE) + passcode.submit() + poll(what = "the new profile screen after the passcode") { passcode.gone() && !exists(Ui.Lock.Unlock) && exists(Ui.NewProfile.Name) } + typeInto(Ui.NewProfile.Name, WORK) + click(Ui.NewProfile.Create) + poll(what = "`$WORK` active and the new profile screen closed") { chatModel.currentUser.value?.displayName == WORK && !exists(Ui.NewProfile.Create) } + waitFor(Ui.Profiles.User(aliceId)) + openProfileMenu(aliceId, "mute") + click(Ui.Profiles.UserMenu(aliceId, "mute")) + poll(what = "alice muted") { userShowsNtfs(aliceId) == false } + check(!exists(Ui.Passcode.Submit) && !exists(Ui.Lock.Unlock)) { "the second profile action asked for the passcode again" } + profiles.close() + alice.userPicker().profiles() + waitFor(Ui.Profiles.User(aliceId)) + openProfileMenu(aliceId, "unmute") + click(Ui.Profiles.UserMenu(aliceId, "unmute")) + passcode.await(CHANGE_PROFILES) + check(userShowsNtfs(aliceId) == false) { "alice was unmuted before the passcode" } + passcode.enter(CODE) + passcode.submit() + poll(what = "alice unmuted after the passcode in the reopened profiles screen") { passcode.gone() && userShowsNtfs(aliceId) == true } + } + + @KnownAppIssue("15.5 item 23", "waiting for the SimpleX Lock notice with `Turn on`", intermittent = true) + @Test fun lock_noticeOfferedWithManyChats() = uiTest { + app("alice") + clearLockNoticeShownViaApi() + var current = restart() + poll(what = "the chat list after the restart") { current.chatList.shown(NOTES) } + settle() + check(chatModel.chats.value.size <= 3 && !AlertManager.shared.hasAlertsShown() && storedSetting(LA_NOTICE_SHOWN) == "false") { + "with ${chatModel.chats.value.size} chats: alert shown ${AlertManager.shared.hasAlertsShown()}, notice stored ${storedSetting(LA_NOTICE_SHOWN)}" + } + newGroupsViaApi("first", "second", "third") + current = restart() + poll(what = "the SimpleX Lock notice with `Turn on`") { seesText(LOCK_NOTICE_TEXT, substring = true) && "Turn on" in textsOf(CONFIRM) } + check(current.chatList.shown(NOTES) && chatModel.chats.value.size > 3 && storedSetting(LA_NOTICE_SHOWN) == "true" && storedSetting(PERFORM_LA) != "true") { + "the notice with ${chatModel.chats.value.size} chats: notice stored ${storedSetting(LA_NOTICE_SHOWN)}, lock ${storedSetting(PERFORM_LA)}" + } + click(CONFIRM) + val passcode = PasscodeScreen(this) + passcode.await(NEW_PASSCODE) + passcode.enter(CODE) + passcode.submit() + passcode.await(CONFIRM_PASSCODE) + passcode.enter(CODE) + passcode.submit() + passcode.confirmAlert("SimpleX Lock turned on") + poll(what = "the lock stored as on with the passcode") { storedSetting(PERFORM_LA) == "true" && decoded(APP_PASSCODE) == CODE && passcode.gone() } + } + + @Test fun lock_passcodeKeypad_digitsBackspaceClearReveal() = uiTest { + val alice = app("alice") + val lock = yourPrivacy(alice).lock() + click(Ui.Lock.Enable) + lock.passcode.await(NEW_PASSCODE) + check(entered().isNullOrEmpty()) { "the passcode field is not empty: ${entered()}" } + lock.passcode.enter("12345") + poll(what = "five masked digits") { entered() == "●●●●●" } + lock.passcode.backspace() + poll(what = "four masked digits after backspace") { entered() == "●●●●" } + click(Ui.Passcode.Entered) + poll(what = "the digits shown after clicking them") { entered() == "1234" } + click(Ui.Passcode.Entered) + poll(what = "the digits masked again") { entered() == "●●●●" } + lock.passcode.clear() + poll(what = "the field cleared") { entered().isNullOrEmpty() } + lock.passcode.submit() + settle() + check(seesText(NEW_PASSCODE) && !seesText(CONFIRM_PASSCODE)) { "Save accepted an empty passcode" } + lock.passcode.enter("123") + lock.passcode.submit() + settle() + check(seesText(NEW_PASSCODE) && !seesText(CONFIRM_PASSCODE)) { "Save accepted three digits" } + lock.passcode.enter("4") + lock.passcode.submit() + lock.passcode.await(CONFIRM_PASSCODE) + lock.passcode.enter(CODE) + lock.passcode.submit() + lock.passcode.confirmAlert(PASSCODE_SET) + poll(what = "the lock stored as on with the passcode") { storedSetting(PERFORM_LA) == "true" && decoded(APP_PASSCODE) == CODE } + } + + @Test fun lock_passcodeKeyboardKeys() = uiTest { + val alice = app("alice") + val lock = yourPrivacy(alice).lock() + click(Ui.Lock.Enable) + lock.passcode.await(NEW_PASSCODE) + for (k in listOf(Key.One, Key.Two, Key.Three)) press(k) + poll(what = "three masked digits typed with the keyboard") { entered() == "●●●" } + click(Ui.Passcode.Entered) + poll(what = "the typed digits shown") { entered() == "123" } + press(Key.Enter) + check(seesText(NEW_PASSCODE) && !seesText(CONFIRM_PASSCODE)) { "Enter submitted three digits" } + press(Key.NumPad4) + poll(what = "the NumPad digit added") { entered() == "1234" } + press(Key.Backspace) + poll(what = "the last digit removed by Backspace") { entered() == "123" } + val root = ui.onRoot() + ui.runOnUiThread { root.performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.Backspace) } } } + poll(what = "the field cleared by Ctrl+Backspace") { entered().isNullOrEmpty() } + for (k in listOf(Key.NumPad5, Key.Six, Key.NumPad7, Key.Eight)) press(k) + poll(what = "the new digits typed") { entered() == NEW_CODE } + press(Key.Enter) + lock.passcode.await(CONFIRM_PASSCODE) + for (k in listOf(Key.Five, Key.Six, Key.Seven, Key.Eight)) press(k) + poll(what = "four masked digits on the confirm step") { entered() == "●●●●" } + press(Key.NumPadEnter) + lock.passcode.confirmAlert(PASSCODE_SET) + poll(what = "the passcode typed with the keyboard stored") { storedSetting(PERFORM_LA) == "true" && decoded(APP_PASSCODE) == NEW_CODE } + } + + @Test fun selfDestruct_changePasscode() = uiTest { + val alice = app("alice") + val lock = yourPrivacy(alice).lock() + lock.enable(CODE) + click(Ui.Lock.SelfDestructInfo) + poll(what = "the Self-destruct page with its three items") { SD_INFO.all { seesText(it) } && !exists(Ui.Lock.Enable) } + check(storedSetting(SELF_DESTRUCT) != "true") { "the info row turned self-destruct on" } + escape() + poll(what = "back on SimpleX Lock") { exists(Ui.Lock.Enable) && !seesText(SD_INFO.first()) } + enableSelfDestruct(lock, SD_CODE) + click(Ui.Lock.ChangeSelfDestruct) + lock.passcode.await(CURRENT_PASSCODE) + lock.passcode.enter(CODE) + lock.passcode.submit() + lock.passcode.await(NEW_PASSCODE) + check(seesText("Self-destruct")) { "the New Passcode screen does not name self-destruct: ${texts().take(30)}" } + lock.passcode.enter(NEW_SD_CODE) + lock.passcode.submit() + lock.passcode.await(CONFIRM_PASSCODE) + lock.passcode.enter(NEW_SD_CODE) + lock.passcode.submit() + lock.passcode.confirmAlert("Self-destruct passcode changed!") + poll(what = "the new self-destruct passcode stored, the app passcode kept") { decoded(SD_PASSCODE) == NEW_SD_CODE && decoded(APP_PASSCODE) == CODE } + click(Ui.Lock.SelfDestruct) + lock.passcode.await(CURRENT_PASSCODE) + lock.passcode.enter(WRONG) + lock.passcode.submit() + lock.passcode.authFailed() + check(storedSetting(SELF_DESTRUCT) == "true" && decoded(SD_PASSCODE) == NEW_SD_CODE) { "a wrong passcode turned self-destruct off" } + click(Ui.Lock.SelfDestruct) + lock.passcode.await(CURRENT_PASSCODE) + lock.passcode.enter(CODE) + lock.passcode.submit() + poll(what = "self-destruct off, its passcode removed and its options hidden") { + lock.passcode.gone() && storedSetting(SELF_DESTRUCT) == "false" && storedSetting(SD_PASSCODE) == null && storedSetting(SD_IV) == null && + !exists(Ui.Lock.SelfDestructName) && !exists(Ui.Lock.ChangeSelfDestruct) + } + lock.close() + restart() + val passcode = PasscodeScreen(this) + passcode.await(ENTER_PASSCODE) + passcode.enter(NEW_SD_CODE) + passcode.submit() + passcode.authFailed() + check(AppLock.userAuthorized.value == false && coreUserNames() == listOf("alice")) { "the old self-destruct passcode acted after self-destruct was turned off: ${coreUserNames()}" } + poll(what = "the Unlock button") { exists(Ui.Lock.Unlock) && passcode.gone() } + click(Ui.Lock.Unlock) + passcode.await(ENTER_PASSCODE) + passcode.enter(CODE) + passcode.submit() + awaitUnlocked(passcode) + } + + private fun UiScope.awaitUnlocked(passcode: PasscodeScreen) = + poll(what = "the passcode screen to close and the app to unlock") { passcode.gone() && !exists(Ui.Lock.Unlock) && AppLock.userAuthorized.value == true } + + private fun UiScope.hideAndReturn() { + ui.runOnUiThread { + AppLock.appWasHidden() + AppLock.recheckAuthState() + } + ui.waitForIdle() + } + + private fun UiScope.stopChatAndConfirm() { + click(Ui.Database.RunChat) + poll(what = "the Stop chat? alert with Stop") { seesText("Stop chat?") && "Stop" in textsOf(CONFIRM) } + click(CONFIRM) + poll(what = "the Stop chat? alert to close") { !seesText("Stop chat?") } + } + + private fun UiScope.enableSelfDestruct(lock: LockScreen, code: String) { + click(Ui.Lock.SelfDestruct) + lock.passcode.await(CURRENT_PASSCODE) + lock.passcode.enter(CODE) + lock.passcode.submit() + lock.passcode.await(SET_PASSCODE) + lock.passcode.enter(code) + lock.passcode.submit() + lock.passcode.await(CONFIRM_PASSCODE) + lock.passcode.enter(code) + lock.passcode.submit() + lock.passcode.confirmAlert("Self-destruct passcode enabled!") + poll(what = "self-destruct stored as on with its passcode") { storedSetting(SELF_DESTRUCT) == "true" && decoded(SD_PASSCODE) == code && exists(Ui.Lock.ChangeSelfDestruct) } + } + + private fun UiScope.entered(): String? = textOf(Ui.Passcode.Entered) + + private fun UiScope.press(key: Key) { + val root = ui.onRoot() + ui.runOnUiThread { root.performKeyInput { pressKey(key) } } + ui.waitForIdle() + } + + private fun clearLockNoticeShownViaApi() = appPrefs.laNoticeShown.set(false) + + private fun UiScope.decoded(key: String): String? = storedSetting(key)?.let { String(Base64.getDecoder().decode(it)) } + + private fun coreUserNames(): List = runBlocking { chatController.listUsers(null) }.map { it.user.displayName } + + private companion object { + const val CODE = "1234" + const val MISMATCH = "1243" + const val WRONG = "9999" + const val NEW_CODE = "5678" + const val SD_CODE = "4321" + const val WIPED_NAME = "wiped" + const val BEFORE_WIPE = "before the wipe" + const val NOTES = "Private notes" + const val NOTE = "written after unlocking" + const val CURRENT_PASSCODE = "Current Passcode" + const val SET_PASSCODE = "Set passcode" + const val PERFORM_LA = "PerformLA" + const val LOCK_DELAY = "LocalAuthenticationLockDelay" + const val APP_PASSCODE = "EncryptedAppPassphrase" + const val SD_PASSCODE = "EncryptedSelfDestructPassphrase" + const val SELF_DESTRUCT = "LocalAuthenticationSelfDestruct" + const val SD_NAME = "LocalAuthenticationSelfDestructDisplayName" + const val SD_IV = "InitializationVectorSelfDestructPassphrase" + const val LA_NOTICE_SHOWN = "LANoticeShown" + const val NEW_SD_CODE = "8765" + const val WORK = "work" + const val LOG_IN_REASON = "Log in using your credential" + const val MIGRATE_TITLE = "Migrate device" + const val CHANGE_PROFILES = "Change chat profiles" + const val LOCK_NOTICE_TEXT = "To protect your information, turn on SimpleX Lock." + val CONFIRM = Ui.Alert.Confirm(Ui.AlertSource.Shared) + val SD_INFO = listOf( + "If you enter your self-destruct passcode while opening the app:", + "All app data is deleted.", + "App passcode is replaced with self-destruct passcode.", + "An empty chat profile with the provided name is created, and the app opens as usual.", + ) + val DELAYS = listOf(10 to "10 seconds", 30 to "30 seconds", 60 to "1 minutes", 180 to "3 minutes", 600 to "10 minutes", 0 to "Immediately") + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/NotesDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/NotesDesktopTest.kt new file mode 100644 index 0000000000..28f4d5511d --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/NotesDesktopTest.kt @@ -0,0 +1,66 @@ +package chat.simplex.app.ui.desktop + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.withKeyDown +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.common.model.CIFile +import chat.simplex.common.model.readCryptoFile +import chat.simplex.common.platform.getLoadedFilePath +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class NotesDesktopTest : UiTest() { + @Test fun notes_pasteFile_storedLocally_deleteRemovesFile() = uiTest { + val alice = app("alice") + val source = File(exchangeDir(), "plan.txt").apply { writeText(PLAN) } + val notes = alice.chatList.open("Private notes") + waitFor(Ui.Chat.Message) + check(!seesDescription("Cancel file preview") && !seesText("plan.txt")) { "a file preview is shown before the paste" } + setClipboardText(source.absolutePath) + pressCtrlV(Ui.Chat.Message) + poll(what = "the preview of plan.txt, with the path not typed into the message field") { + seesDescription("Cancel file preview") && seesText("plan.txt") && editableText(Ui.Chat.Message).isNullOrEmpty() + } + click(Ui.Chat.Send) + val id = notes.itemId("the note with plan.txt") { it.file?.fileName == "plan.txt" } + var stored: File? = null + poll(what = "plan.txt stored in the app's files") { + stored = getLoadedFilePath(notes.item(id)?.file)?.let { File(it) } + stored?.exists() == true + } + check(stored!!.canonicalPath != source.canonicalPath) { "the note points at the pasted file instead of a stored copy" } + assertEquals(PLAN, storedText(notes.item(id)!!.file!!, stored!!)) + rightClick(Ui.Chat.Item(id)) + click(Ui.Chat.ItemMenu(id, "delete")) + poll(what = "the `Delete message?` alert with `Delete for me` and without `Delete for everyone`") { exists(FOR_ME) && !exists(FOR_EVERYONE) } + click(FOR_ME) + poll(what = "the note and its stored copy of plan.txt deleted") { notes.item(id) == null && !exists(Ui.Chat.Item(id)) && !stored!!.exists() } + assertEquals("the pasted source file changed", PLAN, source.readText()) + } + + private fun UiScope.pressCtrlV(key: UiKey) { + val node = ui.onNode(matcher(key)) + ui.runOnUiThread { node.performSemanticsAction(SemanticsActions.RequestFocus) } + ui.runOnUiThread { node.performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.V) } } } + ui.waitForIdle() + } + + private fun storedText(file: CIFile, stored: File): String { + val args = file.fileSource?.cryptoArgs + return (if (args != null) readCryptoFile(stored.absolutePath, args) else stored.readBytes()).toString(Charsets.UTF_8) + } + + private companion object { + const val PLAN = "Plan kept in private notes" + val FOR_ME = Ui.Alert.Option(Ui.AlertSource.Shared, "forMe") + val FOR_EVERYONE = Ui.Alert.Option(Ui.AlertSource.Shared, "forEveryone") + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/PrivacyDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/PrivacyDesktopTest.kt new file mode 100644 index 0000000000..f75cfce936 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/PrivacyDesktopTest.kt @@ -0,0 +1,108 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiEnv +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.msgContent +import chat.simplex.app.ui.harness.yourPrivacy +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import com.sun.net.httpserver.HttpServer +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList + +class PrivacyDesktopTest : UiTest() { + @Test fun privacy_linkPreviewsOff_noPreview() = uiTest { + PreviewSite().use { site -> + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val aliceChat = "@${bob.contactId("alice")}" + check(storedSetting(LINK_PREVIEWS) != "false") { "Send link previews is stored as off before it is turned off" } + val privacy = yourPrivacy(alice) + click(Ui.Privacy.LinkPreviews) + poll(what = "Send link previews stored as off") { storedSetting(LINK_PREVIEWS) == "false" } + privacy.close() + val chat = alice.chatList.open("bob") + val off = site.url("off.jpg") + typeInto(Ui.Chat.Message, off) + poll(what = "the app to finish handling the typed link") { UiIdle.inFlight().isEmpty() } + check(!seesDescription(PREVIEW_IMAGE) && site.requests.isEmpty()) { "with previews off: preview shown ${seesDescription(PREVIEW_IMAGE)}, requests ${site.requests}" } + chat.sendComposed() + bob.expectsMessage("alice> $off", CONNECT_TIMEOUT_MS) + assertEquals("text", bob.msgContent(aliceChat, off)?.get("type")?.jsonPrimitive?.content) + val again = yourPrivacy(alice) + click(Ui.Privacy.LinkPreviews) + poll(what = "Send link previews stored as on") { storedSetting(LINK_PREVIEWS) == "true" } + again.close() + val on = site.url("on.jpg") + typeInto(Ui.Chat.Message, on) + poll(what = "the on.jpg link preview in the compose area") { seesDescription(PREVIEW_IMAGE) && seesText("on.jpg") } + chat.sendComposed() + bob.expectsMessage("alice> $on", CONNECT_TIMEOUT_MS) + val content = bob.msgContent(aliceChat, on) + assertEquals("link", content?.get("type")?.jsonPrimitive?.content) + assertEquals("on.jpg", content?.get("preview")?.jsonObject?.get("title")?.jsonPrimitive?.content) + assertEquals(listOf("/on.jpg"), site.requests.toList()) + } + } + + @Test fun privacy_removeLinkTracking() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + disableLinkPreviewsViaApi() + check(storedSetting(SANITIZE_LINKS) != "true") { "Remove link tracking is stored as on before it is turned on" } + val chat = alice.chatList.open("bob") + enterText(Ui.Chat.Message, TRACKED_LINK) + poll(what = "the typed link kept with Remove link tracking off") { editableText(Ui.Chat.Message) == TRACKED_LINK } + chat.sendComposed() + bob.expectsMessage("alice> $TRACKED_LINK", CONNECT_TIMEOUT_MS) + val privacy = yourPrivacy(alice) + click(Ui.Privacy.SanitizeLinks) + poll(what = "Remove link tracking stored as on") { storedSetting(SANITIZE_LINKS) == "true" } + privacy.close() + enterText(Ui.Chat.Message, TRACKED_LINK) + poll(what = "the tracking parameter removed from the typed link") { editableText(Ui.Chat.Message) == CLEAN_LINK } + chat.sendComposed() + bob.expectsMessage("alice> $CLEAN_LINK", CONNECT_TIMEOUT_MS) + } + + private fun disableLinkPreviewsViaApi() = appPrefs.privacyLinkPreviews.set(false) + + private class PreviewSite : AutoCloseable { + val requests = CopyOnWriteArrayList() + private val image = File(UiEnv.repoRoot, "tests/fixtures/test.jpg").readBytes() + private val server = HttpServer.create(InetSocketAddress(InetAddress.getByName(HOST), 0), 0).apply { + createContext("/") { exchange -> + requests.add(exchange.requestURI.path) + exchange.responseHeaders.add("Content-Type", "image/jpeg") + exchange.sendResponseHeaders(200, image.size.toLong()) + exchange.responseBody.use { it.write(image) } + } + start() + } + + fun url(name: String) = "http://$HOST:${server.address.port}/$name" + + override fun close() = server.stop(0) + } + + private companion object { + const val LINK_PREVIEWS = "PrivacyLinkPreviews" + const val PREVIEW_IMAGE = "link preview image" + const val NOTES = "Private notes" + const val HOST = "127.0.0.1" + const val SANITIZE_LINKS = "PrivacySanitizeLinks" + const val TRACKED_LINK = "https://example.com/page?q=chat&utm_source=app" + const val CLEAN_LINK = "https://example.com/page?q=chat" + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ReceiveFileDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ReceiveFileDesktopTest.kt new file mode 100644 index 0000000000..4e4afb1f62 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ReceiveFileDesktopTest.kt @@ -0,0 +1,43 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.itemFileOf +import chat.simplex.app.ui.harness.onEdt +import chat.simplex.app.ui.harness.sendsFileTo +import chat.simplex.common.model.CIFileStatus +import chat.simplex.common.simplexWindowState +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class ReceiveFileDesktopTest : UiTest() { + @Test fun rcvFile_clickCompleted_opensSaveDialog() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob.sendsFileTo("alice", bob.createFile(LIST_NAME, LIST), 1, LIST_NAME) + val chat = alice.chatList.open("bob") + val id = chat.itemId("the item of $LIST_NAME") { it.file?.fileName == LIST_NAME } + val dialog = simplexWindowState.saveDialog + check(itemFileOf(id)?.fileStatus is CIFileStatus.RcvInvitation && !dialog.isAwaiting) { "$LIST_NAME was received or a save dialog was open before the click" } + click(Ui.Chat.ItemFile(id)) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "$LIST_NAME received and the save dialog opened for it") { + itemFileOf(id)?.fileStatus is CIFileStatus.RcvComplete && dialog.isAwaiting + } + val first = File(exchangeDir(), "first-$LIST_NAME").apply { delete() } + onEdt { dialog.onResult(first) } + poll(what = "the received $LIST_NAME saved to ${first.name}") { !dialog.isAwaiting && first.length() == LIST.length.toLong() } + assertEquals(LIST, first.readText()) + val second = File(exchangeDir(), "second-$LIST_NAME").apply { delete() } + saveFileAs(second) { click(Ui.Chat.ItemFile(id)) } + poll(what = "the completed $LIST_NAME saved again to ${second.name} after a click") { second.length() == LIST.length.toLong() } + assertEquals(LIST, second.readText()) + } + + private companion object { + const val LIST_NAME = "list.txt" + const val LIST = "Shopping list from bob: apples and pears" + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/RemoteDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/RemoteDesktopTest.kt new file mode 100644 index 0000000000..014da0c86e --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/RemoteDesktopTest.kt @@ -0,0 +1,114 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.App +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitMobileSession +import chat.simplex.app.ui.harness.connectPeers +import chat.simplex.app.ui.harness.connectRemoteCtrl +import chat.simplex.app.ui.harness.endMobileSessionViaApi +import chat.simplex.app.ui.harness.linkMobile +import chat.simplex.app.ui.harness.linkMobileHost +import chat.simplex.app.ui.harness.setDeviceName +import chat.simplex.app.ui.harness.stopRemoteCtrl +import chat.simplex.app.ui.harness.verifyRemoteCtrl +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.chatModel +import chat.simplex.common.simplexWindowState +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Test + +class RemoteDesktopTest : UiTest() { + @Test fun linkMobile_newPairing_verifyAndConnect() = uiTest { + val alice = app("alice") + enableDeveloperToolsViaApi() + val mobile = cli("mobile") + mobile.setDeviceName(MOBILE) + val link = linkMobile(alice) + val desktopName = link.deviceName() + val invitation = link.addMobile() + check(!seesText("Verify connection") && !seesText("New mobile device")) { "the pairing screen shows a mobile before one connected" } + val offer = mobile.connectRemoteCtrl(invitation) + assertEquals("the controller name the mobile sees", desktopName, offer.ctrlName) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Verify connection` with `New mobile device` and the mobile's session code `${offer.sessionCode.take(23)}`") { + seesText("Verify connection") && seesText("New mobile device") && seesText(offer.sessionCode.take(23)) && !seesDescription("QR Code") + } + check(chatModel.currentRemoteHost.value == null) { "the desktop switched to the mobile before the mobile verified the session code" } + assertEquals("the controller name in the mobile's session", desktopName, mobile.verifyRemoteCtrl(offer.sessionCode)) + val rh = awaitMobileSession(MOBILE) + alice.userPicker() + poll(what = "the user picker with the `$MOBILE` and `This device` pills and the mobile's profile") { + MOBILE in textsOf(Ui.UserPicker.Device(rh)) && "This device" in textsOf(Ui.UserPicker.Device(null)) && + "mobile" in textsOf(Ui.UserPicker.User(chatModel.currentUser.value!!.userId)) + } + escape() + poll(what = "the user picker to close") { !exists(Ui.UserPicker.Sheet) } + endMobileSessionViaApi(mobile) + } + + @Test fun linkMobile_remoteChatsVisible_sendMessage() = uiTest { + val alice = app("alice") + enableDeveloperToolsViaApi() + val mobile = cli("mobile") + mobile.setDeviceName(MOBILE) + val bob = cli("bob") + connectPeers(mobile, bob) + check(chatModel.chats.value.none { it.chatInfo.chatViewName == "bob" }) { "alice has a chat with bob on the desktop" } + val rh = linkMobileHost(alice, mobile, MOBILE) + val bobId = alice.chatList.chatId("bob") + val row = Ui.ChatList.Row(bobId, rh) + poll(what = "the mobile's chat with bob in the desktop's chat list") { exists(row) && "bob" in textsOf(row) } + click(row) + poll(what = "the mobile's chat with bob to open") { chatModel.chatId.value == bobId } + ChatScreen(this).send("hi from desktop") + bob.eventuallyMessage("mobile> hi from desktop", CONNECT_TIMEOUT_MS) + bob sends "@mobile hello desktop" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's reply on the desktop") { seesText("hello desktop", substring = true) } + endMobileSessionViaApi(mobile) + } + + @Test fun linkMobile_disconnectFromDesktop() = uiTest { + val alice = app("alice") + enableDeveloperToolsViaApi() + val mobile = cli("mobile") + mobile.setDeviceName(MOBILE) + val rh = linkMobileHost(alice, mobile, MOBILE) + alice.userPicker() + click(Ui.UserPicker.Device(rh).part("disconnect")) + mobile.eventually("remote controller stopped", CONNECT_TIMEOUT_MS) + backOnThisDevice(alice, rh) + } + + @Test fun linkMobile_mobileStopsSession() = uiTest { + val alice = app("alice") + enableDeveloperToolsViaApi() + val mobile = cli("mobile") + mobile.setDeviceName(MOBILE) + val rh = linkMobileHost(alice, mobile, MOBILE) + check(disconnectedToasts() == 0) { "a `was disconnected` toast before the mobile stopped" } + mobile.stopRemoteCtrl() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Mobile $MOBILE was disconnected` toast") { disconnectedToasts() == 1 } + backOnThisDevice(alice, rh) + } + + private fun UiScope.backOnThisDevice(alice: App, rh: Long) { + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice's own profile back on the desktop") { + chatModel.currentRemoteHost.value == null && chatModel.currentUser.value?.displayName == "alice" && !chatModel.switchingUsersAndHosts.value + } + alice.userPicker() + poll(what = "the user picker with only the `This device` pill and alice's profile") { + exists(Ui.UserPicker.Device(null)) && !exists(Ui.UserPicker.Device(rh)) && "alice" in textsOf(Ui.UserPicker.User(chatModel.currentUser.value!!.userId)) + } + } + + private fun enableDeveloperToolsViaApi() = appPrefs.developerTools.set(true) + + private fun disconnectedToasts(): Int = simplexWindowState.toasts.toList().count { (text, _) -> MOBILE in text && "was disconnected" in text } + + private companion object { + const val MOBILE = "Pixel test" + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/SendAttachmentDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/SendAttachmentDesktopTest.kt new file mode 100644 index 0000000000..50b5ecc2d7 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/SendAttachmentDesktopTest.kt @@ -0,0 +1,66 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.CliPeer +import chat.simplex.app.ui.harness.UiEnv +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.onEdt +import chat.simplex.app.ui.harness.openAttachmentSheet +import chat.simplex.app.ui.harness.receivesFile +import chat.simplex.common.simplexWindowState +import chat.simplex.common.ui.Ui +import org.junit.Test +import java.io.File + +class SendAttachmentDesktopTest : UiTest() { + @Test fun attachImage_textOnLastImageOnly() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val fixture = File(UiEnv.repoRoot, "tests/fixtures/test.jpg") + val first = fixture.copyTo(File(exchangeDir(), "first.jpg"), overwrite = true) + val second = fixture.copyTo(File(exchangeDir(), "second.jpg"), overwrite = true) + alice.chatList.open("bob") + waitFor(Ui.Chat.Message) + check(!seesDescription("Cancel image preview")) { "an image preview is shown before an image is picked" } + openAttachmentSheet() + openFiles(listOf(first)) { click(Ui.Chat.Attach("image")) } + poll(what = "the preview of first.jpg") { seesDescription("Cancel image preview") && seesDescription("preview image") } + click(described("Cancel image preview")) + poll(what = "the image preview removed") { !seesDescription("Cancel image preview") && !seesDescription("preview image") } + openAttachmentSheet() + openFiles(listOf(first, second)) { click(Ui.Chat.Attach("image")) } + poll(what = "the previews of first.jpg and second.jpg") { seesDescription("Cancel image preview") && seesDescription("preview image") } + typeInto(Ui.Chat.Message, "caption") + click(Ui.Chat.Send) + poll(what = "the previews and the caption cleared after Send") { !seesDescription("Cancel image preview") && editableText(Ui.Chat.Message).isNullOrEmpty() } + val firstName = bob.receivesImageOffer(1) + bob expectsMessage "alice> caption" + val secondName = bob.receivesImageOffer(2) + bob.receivesFile(1, firstName, "alice") + bob.receivesFile(2, secondName, "alice") + } + + private fun UiScope.openFiles(files: List, action: () -> Unit) { + val dialog = simplexWindowState.openMultipleDialog + action() + poll(what = "the open files dialog") { dialog.isAwaiting } + onEdt { dialog.onResult(files) } + poll(what = "the open files dialog to close") { !dialog.isAwaiting } + } + + private fun CliPeer.receivesImageOffer(fileId: Int): String { + val line = nextLine(CONNECT_TIMEOUT_MS) + val m = IMAGE_OFFER.matchEntire(line) ?: throw AssertionError("$name: expected a compressed image offer, got `$line`; next: ${pending()}") + check(m.groupValues[2].toLong() <= MAX_COMPRESSED_BYTES) { "${m.groupValues[1]} was sent uncompressed: ${m.groupValues[2]} bytes" } + this expects "use /fr $fileId [/ | ] to receive it" + return m.groupValues[1] + } + + private companion object { + val IMAGE_OFFER = Regex("""(?:\d{2}:\d{2} )?alice> sends file (IMG_\S+\.jpg) \(.+ / (\d+) bytes\)""") + const val MAX_COMPRESSED_BYTES = 255L * 1024 + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/TextSelectionDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/TextSelectionDesktopTest.kt new file mode 100644 index 0000000000..a4c39622f8 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/TextSelectionDesktopTest.kt @@ -0,0 +1,63 @@ +package chat.simplex.app.ui.desktop + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.withKeyDown +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.common.ui.Ui +import org.junit.Test + +class TextSelectionDesktopTest : UiTest() { + @Test fun textSelection_dragAcrossItems_copyButton() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + for (m in MESSAGES) bob sends "@alice $m" + val chat = alice.chatList.open("bob") + val ids = MESSAGES.map { chat.itemId(it) } + check(!exists(Ui.Chat.TextSelectionCopy)) { "the selection Copy button is shown before a selection" } + setClipboardText(UNRELATED) + drag(Ui.Chat.Item(ids[0]), Ui.Chat.Item(ids[2])) + poll(what = "the selection Copy button after the drag") { exists(Ui.Chat.TextSelectionCopy) } + check(clipboardText() == UNRELATED) { "the drag alone changed the clipboard to `${clipboardText()}`" } + click(Ui.Chat.TextSelectionCopy) + poll(what = "the first three messages on the clipboard, oldest first, and the Copy button gone") { + clipboardText() == MESSAGES.take(3).joinToString("\n") && !exists(Ui.Chat.TextSelectionCopy) + } + } + + @Test fun textSelection_ctrlC_copies() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + for (m in MESSAGES) bob sends "@alice $m" + val chat = alice.chatList.open("bob") + val ids = MESSAGES.map { chat.itemId(it) } + setClipboardText(UNRELATED) + drag(Ui.Chat.Message, Ui.Chat.Item(ids[1])) + pressCtrlC() + check(!exists(Ui.Chat.TextSelectionCopy) && clipboardText() == UNRELATED) { "a drag that started outside the messages selected text: clipboard `${clipboardText()}`" } + drag(Ui.Chat.Item(ids[1]), Ui.Chat.Item(ids[1])) + poll(what = "the selection Copy button after selecting `${MESSAGES[1]}`") { exists(Ui.Chat.TextSelectionCopy) } + pressCtrlC() + poll(what = "`${MESSAGES[1]}` on the clipboard after Ctrl+C") { clipboardText() == MESSAGES[1] } + setClipboardText(UNRELATED) + click(Ui.Chat.Item(ids[0])) + poll(what = "the selection cleared by a click") { !exists(Ui.Chat.TextSelectionCopy) } + pressCtrlC() + check(clipboardText() == UNRELATED) { "Ctrl+C after the selection was cleared copied `${clipboardText()}`" } + } + + private fun UiScope.pressCtrlC() { + val root = ui.onRoot() + ui.runOnUiThread { root.performKeyInput { withKeyDown(Key.CtrlLeft) { pressKey(Key.C) } } } + settle() + } + + private companion object { + const val UNRELATED = "unrelated" + val MESSAGES = listOf("first message here", "the middle one", "third and selected", "fourth stays out", "fifth stays out") + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/TimedMessageDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/TimedMessageDesktopTest.kt new file mode 100644 index 0000000000..64c918f723 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/TimedMessageDesktopTest.kt @@ -0,0 +1,44 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.PeerFlags +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.openTimedMenu +import chat.simplex.app.ui.harness.receivedTtlSeconds +import chat.simplex.common.model.shortTimeText +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertTrue +import org.junit.Test + +class TimedMessageDesktopTest : UiTest() { + @Test fun timed_customTime_sendsWithChosenTtl() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(markRead = true)) + connect(bob) + val chat = alice.chatList.open("bob") + typeInto(Ui.Chat.Message, "custom") + openTimedMenu() + click(Ui.Chat.SendTimed.option("custom")) + poll(what = "the `Delete after` picker at the default 5 minutes with `Send`") { + seesText("Delete after") && exists(Ui.TimePicker.Confirm) && "5" in textsOf(Ui.TimePicker.Value) + } + click(Ui.TimePicker.Value) + click(Ui.TimePicker.Value.option(CUSTOM_TTL)) + poll(what = "2 (minutes) picked") { "2" in textsOf(Ui.TimePicker.Value) } + check(storedSetting(PREF) != "$CUSTOM_TTL") { "the custom time is stored as $CUSTOM_TTL before it is sent" } + click(Ui.TimePicker.Confirm) + bob.expectsMessage("alice> custom", CONNECT_TIMEOUT_MS) + val id = chat.itemId("custom") + poll(what = "alice's `custom` with a $CUSTOM_TTL s timer and `${shortTimeText(CUSTOM_TTL)}` in its meta") { + chat.item(id)?.meta?.itemTimed?.ttl == CUSTOM_TTL && shortTimeText(CUSTOM_TTL) in textsOf(Ui.Chat.Item(id)) + } + val ttl = bob.receivedTtlSeconds("alice", "custom") + assertTrue("bob's `custom` is deleted $ttl s after it was sent, expected about $CUSTOM_TTL s", ttl in (CUSTOM_TTL - 2L)..(CUSTOM_TTL + 10L)) + poll(what = "the custom time stored as $CUSTOM_TTL s") { storedSetting(PREF) == "$CUSTOM_TTL" } + } + + private companion object { + const val CUSTOM_TTL = 120 + const val PREF = "CustomDisappearingMessageTime" + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/CliPeer.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/CliPeer.kt index 6482142023..a46c156b67 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/CliPeer.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/CliPeer.kt @@ -6,7 +6,7 @@ import java.io.File import java.io.InputStreamReader import java.util.concurrent.TimeUnit -class CliPeer(name: String, private val dir: File, private val smpServer: String, private val flags: PeerFlags, waiter: Waiter) : PeerLines(name, waiter) { +class CliPeer(name: String, private val dir: File, private val flags: PeerFlags, waiter: Waiter) : PeerLines(name, waiter) { private val transcript = File(dir, "transcript.log") private lateinit var screen: VtScreen private var proc: PtyProcess? = null @@ -20,10 +20,11 @@ class CliPeer(name: String, private val dir: File, private val smpServer: String if (!File(dir, "simplex_v1_chat.db").exists()) { File(UiEnv.runDir, "peer-template").listFiles { f -> f.name.startsWith("simplex_v1_") }?.forEach { it.copyTo(File(dir, it.name)) } } - val args = mutableListOf(UiEnv.cli, "-d", File(dir, "simplex_v1").absolutePath, "--user-display-name", name, "-s", smpServer, "--xftp-server", TestServers.xftp1) + val args = mutableListOf(UiEnv.cli, "-d", File(dir, "simplex_v1").absolutePath, "--user-display-name", name, "-s", TestServers.address(flags.smpServer), "--xftp-server", TestServers.address(flags.xftpServer)) if (flags.reactions) args += "--reactions" if (flags.markRead) args += "--mark-read" if (flags.fullLinks) args += "--show-full-links" + if (flags.relay) args += "--relay" val cmd = arrayOf("setpriv", "--pdeathsig", "KILL", "sh", "-c", "stty -echo -icanon; exec \"\$0\" \"\$@\"") + args val env = System.getenv().toMutableMap().apply { put("HOME", dir.absolutePath); put("TMPDIR", File(dir, "tmp").absolutePath) @@ -45,11 +46,22 @@ class CliPeer(name: String, private val dir: File, private val smpServer: String } catch (_: Exception) {} }, "cli-$name").apply { isDaemon = true; start() } started = false + relayPromptAnswered = false startDeadline = System.currentTimeMillis() + START_TIMEOUT_MS } private var started = true private var startDeadline = 0L + private var relayPromptAnswered = false + + var relayLink: String? = null + private set + + val relayAddress: String + get() { + ensureStarted() + return relayLink ?: error("$name printed no chat relay address; start it with PeerFlags(relay = true) on a new database") + } fun awaitStart() = ensureStarted() @@ -58,9 +70,14 @@ class CliPeer(name: String, private val dir: File, private val smpServer: String private fun ensureStarted() { if (started) return waiter.waitUntil((startDeadline - System.currentTimeMillis()).coerceAtLeast(0), "$name to start") { + answerRelayAddressPrompt() synchronized(lines) { lines.any { it.startsWith("Current user: ") } } && promptShown() } - synchronized(lines) { lines.clear() } + synchronized(lines) { + val i = lines.indexOfFirst { it.endsWith(RELAY_ADDRESS_CREATED) } + if (i >= 0) relayLink = lines.getOrNull(i + 1) + lines.clear() + } started = true } @@ -85,6 +102,13 @@ class CliPeer(name: String, private val dir: File, private val smpServer: String private fun promptShown(): Boolean = screen.cursorRowText().startsWith(">") + private fun answerRelayAddressPrompt() { + if (flags.relay && !relayPromptAnswered && screen.cursorRowText().startsWith(RELAY_ADDRESS_PROMPT)) { + write("y\n") + relayPromptAnswered = true + } + } + private fun write(s: String) { val out = proc!!.outputStream out.write(s.toByteArray(Charsets.UTF_8)); out.flush() @@ -128,6 +152,8 @@ class CliPeer(name: String, private val dir: File, private val smpServer: String const val COLUMNS = 4000 const val ROWS = 50 private const val PROMPT = "> " + private const val RELAY_ADDRESS_PROMPT = "Create relay address (Yn):" + private const val RELAY_ADDRESS_CREATED = "Chat relay address is created:" private const val START_TIMEOUT_MS = 60_000L private const val TYPE_ATTEMPTS = 5 private const val UNCHANGED_MS = 1_500L diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/Socks5Stub.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/Socks5Stub.kt new file mode 100644 index 0000000000..8c8ea99a4e --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/Socks5Stub.kt @@ -0,0 +1,134 @@ +package chat.simplex.app.ui.harness + +import java.io.DataInputStream +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.ConcurrentHashMap + +class Socks5Stub(val port: Int) : AutoCloseable { + private val server = ServerSocket().apply { reuseAddress = true; bind(InetSocketAddress(InetAddress.getLoopbackAddress(), port)) } + private val sockets: MutableSet = ConcurrentHashMap.newKeySet() + private val recorded = ArrayList() + + init { + Thread({ + while (!server.isClosed) { + val client = runCatching { server.accept() }.getOrNull() ?: break + sockets += client + Thread({ serve(client) }, "socks-stub-client").apply { isDaemon = true; start() } + } + }, "socks-stub-$port").apply { isDaemon = true; start() } + } + + fun connections(): List = synchronized(recorded) { recorded.toList() } + + override fun close() { + runCatching { server.close() } + sockets.forEach { runCatching { it.close() } } + sockets.clear() + } + + private fun serve(client: Socket) { + val target = runCatching { handshake(client) }.getOrNull() + if (target == null) { + runCatching { client.close() } + sockets.remove(client) + return + } + sockets += target + Thread({ pump(target.getInputStream(), client.getOutputStream(), client, target) }, "socks-stub-down").apply { isDaemon = true; start() } + pump(client.getInputStream(), target.getOutputStream(), client, target) + } + + private fun handshake(client: Socket): Socket? { + val input = DataInputStream(client.getInputStream()) + val out = client.getOutputStream() + if (input.readUnsignedByte() != VERSION) return null + val methods = ByteArray(input.readUnsignedByte()).also { input.readFully(it) }.map { it.toInt() and 0xFF } + val username = when { + AUTH_USERNAME in methods -> { + out.write(byteArrayOf(VERSION.toByte(), AUTH_USERNAME.toByte())); out.flush() + input.readUnsignedByte() + val user = String(ByteArray(input.readUnsignedByte()).also { input.readFully(it) }, Charsets.UTF_8) + input.readFully(ByteArray(input.readUnsignedByte())) + out.write(byteArrayOf(1, 0)); out.flush() + user + } + AUTH_NONE in methods -> { out.write(byteArrayOf(VERSION.toByte(), AUTH_NONE.toByte())); out.flush(); null } + else -> { out.write(byteArrayOf(VERSION.toByte(), 0xFF.toByte())); out.flush(); return null } + } + if (input.readUnsignedByte() != VERSION) return null + val command = input.readUnsignedByte() + input.readUnsignedByte() + val host = when (input.readUnsignedByte()) { + ATYP_IPV4 -> InetAddress.getByAddress(ByteArray(4).also { input.readFully(it) }).hostAddress + ATYP_DOMAIN -> String(ByteArray(input.readUnsignedByte()).also { input.readFully(it) }, Charsets.US_ASCII) + ATYP_IPV6 -> InetAddress.getByAddress(ByteArray(16).also { input.readFully(it) }).hostAddress + else -> return reply(out, REP_ADDRESS_TYPE) + } + val targetPort = input.readUnsignedShort() + if (command != CMD_CONNECT) return reply(out, REP_COMMAND) + synchronized(recorded) { recorded += SocksConnection(host, targetPort, username) } + val target = runCatching { Socket().apply { connect(InetSocketAddress(host, targetPort), CONNECT_TIMEOUT_MS) } }.getOrNull() + ?: return reply(out, REP_REFUSED) + reply(out, REP_OK) + return target + } + + private fun pump(from: InputStream, to: OutputStream, a: Socket, b: Socket) { + val buf = ByteArray(16384) + try { + while (true) { + val n = from.read(buf) + if (n < 0) break + to.write(buf, 0, n) + to.flush() + } + } catch (_: Exception) { + } finally { + for (s in listOf(a, b)) { runCatching { s.close() }; sockets.remove(s) } + } + } + + private fun reply(out: OutputStream, code: Int): Socket? { + out.write(byteArrayOf(VERSION.toByte(), code.toByte(), 0, ATYP_IPV4.toByte(), 0, 0, 0, 0, 0, 0)) + out.flush() + return null + } + + private companion object { + const val VERSION = 5 + const val AUTH_NONE = 0 + const val AUTH_USERNAME = 2 + const val CMD_CONNECT = 1 + const val ATYP_IPV4 = 1 + const val ATYP_DOMAIN = 3 + const val ATYP_IPV6 = 4 + const val REP_OK = 0 + const val REP_REFUSED = 5 + const val REP_COMMAND = 7 + const val REP_ADDRESS_TYPE = 8 + const val CONNECT_TIMEOUT_MS = 10_000 + } +} + +object SocksProxy { + private var stub: Socks5Stub? = null + + val port: Int get() = TestServers.socksPort + + @Synchronized fun start() { + if (stub == null) stub = Socks5Stub(port) + } + + @Synchronized fun connections(): List = stub?.connections() ?: error("the SOCKS proxy is not started; SocksProxy.start() starts it") + + @Synchronized fun stop() { + stub?.close() + stub = null + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/TestRoot.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/TestRoot.kt index 581ff945de..598fa045e3 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/TestRoot.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/TestRoot.kt @@ -7,13 +7,32 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.AnnotatedString +import java.awt.datatransfer.Clipboard +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException class TestClipboard : ClipboardManager { - @Volatile var content: AnnotatedString? = null + private val system = Clipboard("uitest").also(::installAsSkikoSystemClipboard) + var content: AnnotatedString? + get() = runCatching { system.getData(DataFlavor.stringFlavor) as String }.getOrNull()?.let(::AnnotatedString) + set(v) { system.setContents(v?.let { StringSelection(it.text) } ?: NO_TEXT, null) } override fun getText(): AnnotatedString? = content override fun setText(annotatedString: AnnotatedString) { content = annotatedString } } +private val NO_TEXT = object : Transferable { + override fun getTransferDataFlavors() = emptyArray() + override fun isDataFlavorSupported(flavor: DataFlavor) = false + override fun getTransferData(flavor: DataFlavor): Any = throw UnsupportedFlavorException(flavor) +} + +private fun installAsSkikoSystemClipboard(clipboard: Clipboard) { + val lazy = Class.forName("org.jetbrains.skiko.Actuals_awtKt").getDeclaredField("systemClipboard\$delegate").apply { isAccessible = true }.get(null) + lazy.javaClass.getDeclaredField("_value").apply { isAccessible = true }.set(lazy, clipboard) +} + class RecordingUriHandler : UriHandler { val opened = mutableListOf() override fun openUri(uri: String) { synchronized(opened) { opened += uri } } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/TestServers.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/TestServers.kt index 44f8e84a5a..e5101c0333 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/TestServers.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/TestServers.kt @@ -2,6 +2,7 @@ package chat.simplex.app.ui.harness import java.io.File import java.io.RandomAccessFile +import java.io.Writer import java.nio.channels.FileChannel import java.nio.channels.FileLock import java.util.concurrent.LinkedBlockingQueue @@ -9,6 +10,9 @@ import java.util.concurrent.TimeUnit object TestServers { private const val SLOTS = 120 + const val SOCKS_PORT_OFFSET = 20 + private val BASELINE = setOf("smp1", "xftp1") + private const val REPLY_TIMEOUT_S = 30L private class SlotLock(val index: Int, val channel: FileChannel, val lock: FileLock) @@ -22,43 +26,82 @@ object TestServers { } val slot: Int get() = slotLock.index val portBase: Int get() = 20000 + 100 * slot + val remoteCtrlPort: Int get() = portBase + 90 - private var proc: Process? = null + @Volatile private var proc: Process? = null + private var commands: Writer? = null + private var log: File? = null private val responses = LinkedBlockingQueue() - lateinit var smp1: String - private set - lateinit var xftp1: String - private set + private val addresses = HashMap() + private val running = HashSet() - fun start() { + val smp1: String get() = address("smp1") + val xftp1: String get() = address("xftp1") + + @Synchronized fun launch() { if (proc != null) return val dir = File(UiEnv.jvmDir, "servers").apply { mkdirs() } val p = ProcessBuilder("setpriv", "--pdeathsig", "KILL", UiEnv.servers, dir.absolutePath, portBase.toString()) .directory(UiEnv.repoRoot).redirectErrorStream(true).start() proc = p - val log = File(dir, "servers.log") + commands = p.outputStream.bufferedWriter(Charsets.UTF_8) + val logFile = File(dir, "servers.log").also { log = it } Thread({ p.inputStream.bufferedReader().forEachLine { line -> - log.appendText("$line\n") - if (line.startsWith("ready ") || line.startsWith("error ")) responses.add(line) + logFile.appendText("$line\n") + if (line.startsWith("ready ") || line.startsWith("stopped ") || line.startsWith("error ")) responses.add(line) } }, "uitest-servers").apply { isDaemon = true; start() } - smp1 = ready("smp1", log) - xftp1 = ready("xftp1", log) + for (name in BASELINE) ready(name, reply(name)) } - private fun ready(name: String, log: File): String { - val r = responses.poll(30, TimeUnit.SECONDS) ?: error("uitest-servers did not start $name; see $log") - check(r.startsWith("ready $name ")) { "uitest-servers: $r" } - return r.removePrefix("ready $name ") + @Synchronized fun start(name: String): String = ready(name, command("start", name)) + + @Synchronized fun stop(name: String) { + val r = command("stop", name) + check(r == "stopped $name") { "uitest-servers answered `$r` to `stop $name`; see $log" } + running -= name } + @Synchronized fun reset(name: String): String = ready(name, command("reset", name)) + + @Synchronized fun address(name: String): String = + addresses[name]?.takeIf { name in running } ?: error("server $name is not running; TestServers.start(\"$name\") starts it") + + @Synchronized fun restoreBaseline() { + launch() + (running - BASELINE).forEach { stop(it) } + (BASELINE - running).forEach { start(it) } + } + + val socksPort: Int get() = portBase + SOCKS_PORT_OFFSET + fun configure() = configureServers(smp1, xftp1) - fun stop() { + fun shutdown() { val p = proc ?: return proc = null runCatching { p.outputStream.close() } if (!p.waitFor(10, TimeUnit.SECONDS)) p.destroyForcibly() } + + private fun command(cmd: String, name: String): String { + launch() + val w = commands ?: error("uitest-servers is not running") + responses.clear() + w.write("$cmd $name\n") + w.flush() + return reply(name) + } + + private fun reply(name: String): String { + val r = responses.poll(REPLY_TIMEOUT_S, TimeUnit.SECONDS) ?: error("uitest-servers did not answer for $name in $REPLY_TIMEOUT_S s; see $log") + check(!r.startsWith("error ")) { "uitest-servers: $r; see $log" } + return r + } + + private fun ready(name: String, reply: String): String { + check(reply.startsWith("ready $name ")) { "uitest-servers answered `$reply` for $name; see $log" } + return reply.removePrefix("ready $name ").also { addresses[name] = it; running += name } + } } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiBridge.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiBridge.kt index f5e4efa00b..f837033242 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiBridge.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiBridge.kt @@ -18,7 +18,7 @@ object UiBridge { @JvmStatic fun main(args: Array) { val port = args.getOrNull(0)?.toInt() ?: 7777 UiJvm.setup() - TestServers.start() + TestServers.launch() val dir = File(UiEnv.jvmDir, "bridge-peers").apply { mkdirs() } println("uitest-bridge: smp ${TestServers.smp1}, port base ${TestServers.portBase}, listening on 127.0.0.1:$port, pid ${ProcessHandle.current().pid()}") ServerSocket(port, 1, InetAddress.getLoopbackAddress()).use { server -> @@ -37,22 +37,31 @@ object UiBridge { Peers.stopAll() peers.clear() peerDirs.clear() + SocksProxy.stop() + runCatching { TestServers.restoreBaseline() } } } } } + private fun flag(flags: Set, key: String): String? = flags.firstOrNull { it.startsWith("$key=") }?.removePrefix("$key=") + private fun handle(line: String, dir: File): List { val parts = line.split(" ", limit = 3) return when (parts[0]) { "servers" -> listOf("ok ${TestServers.smp1} ${TestServers.xftp1}") + "remotePort" -> listOf("ok ${TestServers.remoteCtrlPort}") "start" -> { val name = parts[1] val f = parts.getOrElse(2) { "" }.split(",").toSet() - val peer = CliPeer(name, peerDirs.getOrPut(name) { File(dir, "${System.nanoTime()}-$name") }, TestServers.smp1, PeerFlags("reactions" in f, "markRead" in f, "fullLinks" in f), sleepWaiter) + val flags = PeerFlags( + "reactions" in f, "markRead" in f, "fullLinks" in f, "relay" in f, + smpServer = flag(f, "smp") ?: PeerFlags().smpServer, xftpServer = flag(f, "xftp") ?: PeerFlags().xftpServer + ) + val peer = CliPeer(name, peerDirs.getOrPut(name) { File(dir, "${System.nanoTime()}-$name") }, flags, sleepWaiter) Peers.add(peer); peers[name] = peer peer.start(); peer.awaitStart() - listOf("ok") + listOf(peer.relayLink?.let { "ok $it" } ?: "ok") } "type" -> { peers.getValue(parts[1]).types(parts.getOrElse(2) { "" }); listOf("ok") } "file" -> { @@ -62,6 +71,23 @@ object UiBridge { "drain" -> { val ls = peers.getValue(parts[1]).drainLines(); listOf("lines ${ls.size}") + ls } "stop" -> { peers.remove(parts[1])?.stop(); listOf("ok") } "forget" -> { peers.remove(parts[1])?.stop(); peerDirs.remove(parts[1]); listOf("ok") } + "server" -> { + val name = parts.getOrElse(2) { "" } + when (parts[1]) { + "start" -> listOf("ok ${TestServers.start(name)}") + "stop" -> { TestServers.stop(name); listOf("ok") } + "reset" -> listOf("ok ${TestServers.reset(name)}") + "address" -> listOf("ok ${TestServers.address(name)}") + "baseline" -> { TestServers.restoreBaseline(); listOf("ok") } + else -> listOf("err unknown server command ${parts[1]}") + } + } + "socks" -> when (parts[1]) { + "start" -> { SocksProxy.start(); listOf("ok ${SocksProxy.port}") } + "connections" -> SocksProxy.connections().let { cs -> listOf("lines ${cs.size}") + cs.map { "${it.host} ${it.port} ${it.username ?: "-"}" } } + "stop" -> { SocksProxy.stop(); listOf("ok") } + else -> listOf("err unknown socks command ${parts[1]}") + } else -> listOf("err unknown command ${parts[0]}") } } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiJvm.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiJvm.kt index 668661ba2a..193d44d66e 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiJvm.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiJvm.kt @@ -11,7 +11,7 @@ object UiJvm { Runtime.getRuntime().addShutdownHook(Thread { runCatching { UiTestApp.stopCore() } Peers.stopAll() - TestServers.stop() + TestServers.shutdown() ProcessHandle.current().descendants().forEach { it.destroyForcibly() } }) } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTest.kt index 8b90015573..3493ea3861 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTest.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTest.kt @@ -63,6 +63,7 @@ abstract class UiTest { } watchdog.cancel() Peers.stopAll() + SocksProxy.stop() if (passed) dir.deleteRecursively() } } @@ -78,6 +79,13 @@ private const val LIST_PAGES = 20 private const val REPLACE_CHECK_MS = 2_000L private const val SCROLL_PAGE = 0.6f private const val SCROLL_MARGIN_DP = 48f +private const val CHAT_SCROLL_PAGE = 0.8f +private const val DRAG_STEPS = 10 +private const val DRAG_INSET_PX = 4f +private val CHAT_ITEM_TAG = "${chat.simplex.common.ui.Ui.Chat.Item(0).id}[" +private val chatItemNode = SemanticsMatcher("a chat item") { + it.config.getOrElseNullable(androidx.compose.ui.semantics.SemanticsProperties.TestTag) { null }?.startsWith(CHAT_ITEM_TAG) == true +} class UiScope(val ui: SkikoComposeUiTest, private val dir: File) { val clipboard = TestClipboard() @@ -90,17 +98,38 @@ class UiScope(val ui: SkikoComposeUiTest, private val dir: File) { return App(this) } - fun restart(): App { + fun restart( + ready: () -> Boolean = { chat.simplex.common.platform.chatModel.chatDbStatus.value != null && !chat.simplex.common.platform.chatModel.ctrlInitInProgress.value }, + what: String = "the app to start again" + ): App { ui.setContent { } ui.waitForIdle() UiTestApp.restartCore(beforeInit = PrefStores::reloadFromDisk) ui.setContent { TestRoot(clipboard, uriHandler) { AppScreen() } } - poll(timeoutMs = 30_000, what = "the app to start again") { - chat.simplex.common.platform.chatModel.chatDbStatus.value != null && !chat.simplex.common.platform.chatModel.ctrlInitInProgress.value - } + poll(timeoutMs = 30_000, what = what) { ready() } return App(this) } + fun exchangeDir(): File = File(dir, "exchange").apply { mkdirs() } + + fun saveFileAs(file: File, action: () -> Unit) = answerDialog(chat.simplex.common.simplexWindowState.saveDialog, "the save file dialog", file, action) + + fun openFile(file: File, action: () -> Unit) { + val single = chat.simplex.common.simplexWindowState.openDialog + val multiple = chat.simplex.common.simplexWindowState.openMultipleDialog + action() + poll(what = "the open file dialog") { single.isAwaiting || multiple.isAwaiting } + onEdt { if (single.isAwaiting) single.onResult(file) else multiple.onResult(listOf(file)) } + poll(what = "the open file dialog to close") { !single.isAwaiting && !multiple.isAwaiting } + } + + private fun answerDialog(dialog: chat.simplex.common.DialogState, what: String, file: File, action: () -> Unit) { + action() + poll(what = what) { dialog.isAwaiting } + onEdt { dialog.onResult(file) } + poll(what = "$what to close") { !dialog.isAwaiting } + } + fun textOf(key: UiKey): String? { waitFor(key) val textKey = androidx.compose.ui.semantics.SemanticsProperties.Text @@ -128,6 +157,8 @@ class UiScope(val ui: SkikoComposeUiTest, private val dir: File) { fun clipboardText(): String? = clipboard.content?.text + fun setClipboardText(text: String?) { clipboard.content = text?.let { androidx.compose.ui.text.AnnotatedString(it) } } + fun matcher(key: UiKey): SemanticsMatcher = if (key.id == DESCRIBED) hasContentDescription(key.arg as String) and hasClickAction() else hasTestTag(key.tag) @@ -162,6 +193,31 @@ class UiScope(val ui: SkikoComposeUiTest, private val dir: File) { + fun scrollChatTowardOlder(pages: Int = 1): Boolean = scrollChat(pages, towardOlder = true) + + fun scrollChatTowardNewer(pages: Int = 1): Boolean = scrollChat(pages, towardOlder = false) + + private fun scrollChat(pages: Int, towardOlder: Boolean): Boolean { + val list = ui.onNode(SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollToIndex) and SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollBy) and hasAnyDescendant(chatItemNode)) + repeat(pages) { + val items = ui.onAllNodes(chatItemNode).fetchSemanticsNodes() + val tracked = (if (towardOlder) items.minByOrNull { it.boundsInRoot.top } else items.maxByOrNull { it.boundsInRoot.bottom }) + ?: throw AssertionError("no chat items in view to scroll the chat") + val tag = tracked.config[androidx.compose.ui.semantics.SemanticsProperties.TestTag] + val before = tracked.boundsInRoot + val node = list.fetchSemanticsNode() + val dy = node.boundsInRoot.height * CHAT_SCROLL_PAGE + ui.runOnUiThread { node.config[SemanticsActions.ScrollBy].action?.invoke(0f, if (towardOlder) dy else -dy) } + repeat(SCROLL_ANIMATION_FRAMES) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true); ui.waitForIdle() } + val after = ui.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().firstOrNull()?.boundsInRoot + if (after == before) return false + if (after == null || (if (towardOlder) after.top <= before.top else after.bottom >= before.bottom)) { + throw AssertionError("scrolling the chat toward ${if (towardOlder) "older" else "newer"} messages moved $tag the other way: $before -> $after") + } + } + return true + } + fun waitStable(key: UiKey) { var last: androidx.compose.ui.geometry.Rect? = null var count = 0 @@ -183,17 +239,19 @@ class UiScope(val ui: SkikoComposeUiTest, private val dir: File) { val rootBottom = generateSequence(node) { it.parent }.last().boundsInRoot.bottom val b = node.boundsInRoot val placed = b.width > 0f && b.height > 0f - if (placed && b.top >= 0f && b.bottom <= rootBottom) return + val margin = node.layoutInfo.density.density * SCROLL_MARGIN_DP + if (placed && b.top >= 0f && b.bottom <= rootBottom - margin) return val scroller = generateSequence(node.parent) { it.parent } .firstOrNull { it.config.contains(androidx.compose.ui.semantics.SemanticsActions.ScrollBy) } ?: return - val margin = node.layoutInfo.density.density * SCROLL_MARGIN_DP val dy = when { + !placed && node.positionInRoot.y < 0f -> node.positionInRoot.y - margin !placed -> rootBottom * SCROLL_PAGE - b.bottom > rootBottom -> b.bottom - rootBottom + margin + b.bottom > rootBottom - margin -> b.bottom - rootBottom + margin else -> b.top - margin } ui.runOnUiThread { scroller.config[androidx.compose.ui.semantics.SemanticsActions.ScrollBy].action?.invoke(0f, dy) } repeat(SCROLL_ANIMATION_FRAMES) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true); ui.waitForIdle() } + if (placed && ui.onAllNodes(matcher(key)).fetchSemanticsNodes().firstOrNull()?.boundsInRoot == b) return } } @@ -270,6 +328,26 @@ class UiScope(val ui: SkikoComposeUiTest, private val dir: File) { ui.waitForIdle() } + fun drag(from: UiKey, to: UiKey) { + waitFor(from) + waitFor(to) + waitStable(from) + waitStable(to) + waitPopupsClosedUnlessInside(from) + val start = ui.onNode(matcher(from)).fetchSemanticsNode().boundsInRoot.let { androidx.compose.ui.geometry.Offset(it.left + DRAG_INSET_PX, it.top + DRAG_INSET_PX) } + val end = ui.onNode(matcher(to)).fetchSemanticsNode().boundsInRoot.let { androidx.compose.ui.geometry.Offset(it.right - DRAG_INSET_PX, it.bottom - DRAG_INSET_PX) } + val root = ui.onRoot() + ui.runOnUiThread { root.performMouseInput { moveTo(start); press() } } + repeat(FOCUS_SETTLE_FRAMES) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true); ui.waitForIdle() } + for (step in 1..DRAG_STEPS) { + val point = start + (end - start) * (step.toFloat() / DRAG_STEPS) + ui.runOnUiThread { root.performMouseInput { moveTo(point) } } + repeat(FOCUS_SETTLE_FRAMES) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true); ui.waitForIdle() } + } + ui.runOnUiThread { root.performMouseInput { release() } } + repeat(FOCUS_SETTLE_FRAMES) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true); ui.waitForIdle() } + } + fun textsOf(key: UiKey): List { waitFor(key) val textKey = androidx.compose.ui.semantics.SemanticsProperties.Text @@ -301,14 +379,17 @@ class UiScope(val ui: SkikoComposeUiTest, private val dir: File) { ui.onAllNodes((matcher(key) or hasAnyAncestor(matcher(key))) and SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsProperties.EditableText), useUnmergedTree = true) .fetchSemanticsNodes().firstOrNull()?.config?.get(androidx.compose.ui.semantics.SemanticsProperties.EditableText)?.text + fun hintOf(key: UiKey): String? = if (editableText(key).isNullOrEmpty()) textsOf(key).firstOrNull() else null + fun cli(name: String, flags: PeerFlags = PeerFlags()): CliPeer = - CliPeer(name, File(dir, "peers/$name"), TestServers.smp1, flags) { t, what, cond -> poll(t, what, cond = cond) } + CliPeer(name, File(dir, "peers/$name"), flags) { t, what, cond -> poll(t, what, cond = cond) } .also { Peers.add(it); it.start() } fun poll(timeoutMs: Long = 10_000, what: String, screenshot: Boolean = true, cond: () -> Boolean) { val deadline = System.currentTimeMillis() + timeoutMs var busyAtDeadline: Set? = null var lastError: Throwable? = null + var workEnded = false while (true) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true) ui.waitForIdle() @@ -318,10 +399,11 @@ class UiScope(val ui: SkikoComposeUiTest, private val dir: File) { if (now > deadline) { val busy = busyAtDeadline ?: UiIdle.inFlight().map { it.id }.toSet().also { busyAtDeadline = it } val stillBusy = UiIdle.inFlight().any { it.id in busy } - if (!stillBusy || now > deadline + EXTENSION_MS) { + if ((!stillBusy && workEnded) || now > deadline + EXTENSION_MS) { val shot = if (screenshot) failureShot(what) else null throw AssertionError("timed out after $timeoutMs ms waiting for $what; ${lastError?.let { "last condition error: $it; " } ?: ""}in flight: ${UiIdle.inFlight().joinToString { it.what }}; screenshot: $shot; visible texts: ${texts().take(60)}") } + workEnded = !stillBusy } Thread.sleep(16) } diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTestApp.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTestApp.kt index 4cebddc239..d9011fc734 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTestApp.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTestApp.kt @@ -64,7 +64,7 @@ object UiTestApp { coreStarted = true PrefStores.restore() UiState.clearUiState() - TestServers.start() + TestServers.restoreBaseline() DbTemplate.installIfPresent() if (user != null) TestPrefs.preset() initCore() diff --git a/apps/multiplatform/common/src/uiTestShared/README.md b/apps/multiplatform/common/src/uiTestShared/README.md index b87bcb158c..40ce2fb21a 100644 --- a/apps/multiplatform/common/src/uiTestShared/README.md +++ b/apps/multiplatform/common/src/uiTestShared/README.md @@ -75,6 +75,19 @@ colour; `groupInvitation_tapRowShowsJoinAlert` never checked the alert. - Peer output that looks like an error and that the test did not consume explicitly fails the test (section 6). - The CLI inserts the open chat's name after a leading `> `, `! ` or `\ `. Write these commands without the space (`>@alice (quoted) reply`, `!@alice new text`), or the typed line will not match. +### Servers and the SOCKS stub + +- `TestServers.stop(name)`, `start(name)` (returns the address) and `reset(name)` (restart without the store) control + `smp1`, `xftp1`, `smp2` and `xftp2` (plan §0.5.6). Each test JVM has its own servers, and every reset restores the + baseline (`smp1` and `xftp1` running, `smp2` and `xftp2` stopped), so start what the test needs and never rely on + what another test left. +- A peer on the second server: `TestServers.start("smp2")`, then `cli("bob", PeerFlags(smpServer = "smp2"))`. +- Keep outages to a few seconds and use `CONNECT_TIMEOUT_MS` for waits after a start: the agents back off. +- Around an outage CLI peers print `disconnected N connections on server …` and `subscribed N connections on server …`, + so use `eventually*` for the next line after a stop or start, not `expects`. +- `SocksProxy.start()`, `SocksProxy.port` (host `SOCKS_HOST`) and `SocksProxy.connections()` (each CONNECT the stub + received, with the target host, port and username); the harness stops it at the end of the test. + ### Persistence "Persists across restart" means: change the setting through the UI, check `storedSetting(...)` (what is on disk), diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ChatSearchTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ChatSearchTest.kt index 77cd9b83c8..39754c43a8 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ChatSearchTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ChatSearchTest.kt @@ -1,10 +1,23 @@ package chat.simplex.app.ui.chat +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiScope import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitRowText +import chat.simplex.app.ui.harness.closeChatSearch import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.contentFilter +import chat.simplex.app.ui.harness.loadedItemId +import chat.simplex.app.ui.harness.loadedItemIds +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.pickContentFilter +import chat.simplex.app.ui.harness.switchAwayFromChat +import chat.simplex.common.model.MsgContent import chat.simplex.common.ui.Ui import org.junit.Test +private const val FIXTURE_JPG = "tests/fixtures/test.jpg" + class ChatSearchTest : UiTest() { @Test fun search_direct_filtersResults() = uiTest { val alice = app("alice"); val bob = cli("bob") @@ -22,4 +35,117 @@ class ChatSearchTest : UiTest() { matches.all { exists(Ui.Chat.Item(it)) } && !exists(Ui.Chat.Item(other)) } } + + @Test fun search_group_filtersResults() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = ownGroup(alice, "team", bob) + bob sends "#team apples and pears" + val bobs = chat.itemId("bob's `apples and pears`") { it.content.text == "apples and pears" } + bob sends "#team plums" + val plums = chat.itemId("bob's `plums`") { it.content.text == "plums" } + chat.send("pears for bob") + val mine = chat.itemId("pears for bob") + bob.eventuallyMessage("#team alice> pears for bob", CONNECT_TIMEOUT_MS) + chat.search("pears") + poll(what = "bob's and alice's messages with `pears`, without bob's `plums`") { + chat.itemTexts().toSet() == setOf("apples and pears", "pears for bob") && + exists(Ui.Chat.Item(bobs)) && exists(Ui.Chat.Item(mine)) && !exists(Ui.Chat.Item(plums)) + } + replaceText(Ui.Chat.Bar.part("search"), "apples") + poll(what = "only bob's `apples and pears`") { + chat.itemTexts() == listOf("apples and pears") && exists(Ui.Chat.Item(bobs)) && !exists(Ui.Chat.Item(mine)) && !exists(Ui.Chat.Item(plums)) + } + } + + @Test fun search_goToResult_opensAroundItem() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice alpha" + bob sends "@alice the needle" + bob sends "@alice omega" + val chat = alice.chatList.open("bob") + val alpha = chat.itemId("alpha"); val needle = chat.itemId("the needle"); val omega = chat.itemId("omega") + val goTo = Ui.Chat.Item(needle).part("goTo") + check(!exists(goTo)) { "`the needle` has a go-to button outside search" } + chat.search("needle") + poll(what = "only `the needle`, with its go-to button") { + chat.itemTexts() == listOf("the needle") && exists(goTo) && !exists(Ui.Chat.Item(alpha)) && !exists(Ui.Chat.Item(omega)) + } + hideKeyboard() + click(goTo) + poll(what = "the search closed and `the needle` shown with `alpha` and `omega` around it") { + !exists(Ui.Chat.Bar.part("search")) && !exists(goTo) && + exists(Ui.Chat.Item(alpha)) && exists(Ui.Chat.Item(needle)) && exists(Ui.Chat.Item(omega)) + } + } + + @Test fun search_notes_filtersResults() = uiTest { + val alice = app("alice") + val notes = alice.chatList.open("Private notes") + notes.send("keep in mind") + notes.send("ahoy!") + val keep = notes.itemId("keep in mind"); val ahoy = notes.itemId("ahoy!") + check(!exists(Ui.Chat.Bar.part("search"))) { "the search field is open before the search button is clicked" } + hideKeyboard() + click(Ui.Chat.SearchButton) + typeInto(Ui.Chat.Bar.part("search"), "keep") + poll(what = "only `keep in mind`") { + notes.itemTexts() == listOf("keep in mind") && exists(Ui.Chat.Item(keep)) && !exists(Ui.Chat.Item(ahoy)) + } + closeChatSearch() + poll(what = "both notes shown again after the search closed") { + notes.itemTexts().containsAll(listOf("keep in mind", "ahoy!")) && exists(Ui.Chat.Item(keep)) && exists(Ui.Chat.Item(ahoy)) + } + } + + @Test fun contentFilter_types_filterAndReset() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + ownGroup(alice, "team", bob) + switchAwayFromChat(alice) + bob sends "#team no links here" + bob sends "#team check out https://example.com" + bob sends "#team see https://simplex.chat" + bob sends "/f #team ${bob.createFile("list.txt", "apples")}" + bob expects "use /fc 1 to cancel sending" + bob.eventually("completed uploading file 1 (list.txt) for #team", CONNECT_TIMEOUT_MS) + bob sends "/img #team $FIXTURE_JPG" + bob expectsMessage "/f #team $FIXTURE_JPG" + bob expects "use /fc 2 to cancel sending" + bob.eventually("completed uploading file 2 (test.jpg) for #team", CONNECT_TIMEOUT_MS) + bob sends "#team last words" + awaitRowText(alice, "team", "bob: last words") + alice.chatList.open("team") + val plain = loadedItemId("`no links here`") { it.content.text == "no links here" } + val example = loadedItemId("`check out https://example.com`") { it.content.text == "check out https://example.com" } + val simplex = loadedItemId("`see https://simplex.chat`") { it.content.text == "see https://simplex.chat" } + val file = loadedItemId("the list.txt file") { it.file?.fileName == "list.txt" } + val image = loadedItemId("the image") { it.content.msgContent is MsgContent.MCImage } + val last = loadedItemId("`last words`") { it.content.text == "last words" } + val all = setOf(plain, example, simplex, file, image, last) + click(Ui.Chat.ContentFilter) + poll(what = "`Images`, `Files` and `Links` in the content filter menu, without `Videos`, `Voice messages` or `All messages`") { + listOf("Images", "Files", "Links").all { exists(contentFilter(it)) } && listOf("Videos", "Voice", "all").none { exists(contentFilter(it)) } + } + click(contentFilter("Files")) + showsOnly("only the list.txt file", setOf(file), all) + click(Ui.Chat.ContentFilter) + poll(what = "`All messages` in the menu while a filter is on") { exists(contentFilter("all")) } + click(contentFilter("Images")) + showsOnly("only the image", setOf(image), all) + pickContentFilter("Links") + showsOnly("only the two messages with links", setOf(example, simplex), all) + typeInto(Ui.Chat.Bar.part("search"), "example") + showsOnly("only the link message with `example`", setOf(example), all) + hideKeyboard() + pickContentFilter("all") + poll(what = "the filter off: no search field and every message loaded again, `last words` in view") { + !exists(Ui.Chat.Bar.part("search")) && loadedItemIds().containsAll(all) && exists(Ui.Chat.Item(last)) + } + } + + private fun UiScope.showsOnly(what: String, shown: Set, all: Set) = poll(what = what) { + loadedItemIds() == shown && shown.all { exists(Ui.Chat.Item(it)) } && (all - shown).none { exists(Ui.Chat.Item(it)) } + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ChatShellTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ChatShellTest.kt new file mode 100644 index 0000000000..59b9f78a96 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ChatShellTest.kt @@ -0,0 +1,146 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.becomeBot +import chat.simplex.app.ui.harness.closeContactInfo +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.contactOf +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.infoNameText +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.common.model.CIContent +import chat.simplex.common.model.MsgFilter +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.ModalManager +import org.junit.Assert.assertFalse +import org.junit.Test + +class ChatShellTest : UiTest() { + @Test fun directChat_opens_showsToolbarAndBanner() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + assertFalse("a chat toolbar is shown before a chat is opened", exists(TITLE)) + val chat = alice.chatList.open("bob") + val e2ee = chat.itemId("bob's encryption info") { it.content is CIContent.RcvDirectE2EEInfo || it.content is CIContent.SndDirectE2EEInfo } + poll(what = "bob's name in the toolbar, `Your contact` in the banner and quantum resistant encryption info") { + textsOf(TITLE).let { t -> "bob" in t && FULL_NAME !in t } && seesText("Your contact") && + textsOf(Ui.Chat.Item(e2ee)).let { t -> t.any { PQ_E2EE in it } && t.none { "end-to-end encryption" in it } } + } + bob sends """/_profile 1 {"displayName":"bob","fullName":"$FULL_NAME"}""" + bob.eventuallyMatching(Regex("user full name changed to $FULL_NAME \\(your 1 contacts are notified.*\\)"), CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's full name under his name in the toolbar") { textsOf(TITLE).let { "bob" in it && FULL_NAME in it } } + assertFalse("contact info is open before the title is clicked", exists(Ui.Info.Alias)) + chat.info() + poll(what = "bob's contact info with the contact name field") { exists(Ui.Info.Alias) && infoNameText() == "bob" } + closeContactInfo() + } + + @Test fun chatBanner_contextText_perChatType() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.open("bob") + poll(what = "`Your contact` in bob's banner") { seesText("Your contact") && !seesText("Bot") } + backToChatList() + val team = emptyGroup(alice, "team") + val e2ee = team.itemId("the encryption info of #team") { it.content is CIContent.SndGroupE2EEInfo } + poll(what = "`team` in the toolbar, `Your group` in the banner and the encryption info without quantum resistance") { + "team" in textsOf(TITLE) && seesText("Your group") && !seesText("Your contact") && + textsOf(Ui.Chat.Item(e2ee)).let { t -> t.any { GROUP_E2EE in it } && t.none { "quantum resistant" in it } } + } + backToChatList() + hostGroup(bob, "club", "alice") + joinGroupFromList(alice, bob, "club") + alice.chatList.open("club") + poll(what = "`Group` in the banner of bob's #club") { "club" in textsOf(TITLE) && seesText("Group") && !seesText("Your group") } + backToChatList() + bob.becomeBot() + alice.chatList.open("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Bot` instead of `Your contact` in bob's banner once his profile says he is a bot") { + seesText("Bot") && !seesText("Your contact") + } + } + + @Test fun toolbarMenu_direct_items() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.open("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice's contact with bob ready") { contactOf("bob").ready } + openBarMenu() + poll(what = "`Search` and `Mute` in bob's toolbar menu, without `Invite members`") { + exists(SEARCH) && ntfLabel() == "Mute" && !exists(ADD_MEMBERS) + } + click(NTF) + poll(what = "bob muted") { contactOf("bob").chatSettings.enableNtfs == MsgFilter.None } + openBarMenu() + poll(what = "`Unmute` in bob's toolbar menu") { ntfLabel() == "Unmute" } + click(NTF) + poll(what = "bob unmuted") { contactOf("bob").chatSettings.enableNtfs == MsgFilter.All } + openBarMenu() + poll(what = "`Mute` in bob's toolbar menu again") { ntfLabel() == "Mute" } + click(SEARCH) + waitFor(Ui.Chat.Bar.part("search")) + } + + @Test fun toolbarMenu_group_items() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + emptyGroup(alice, "team") + openBarMenu() + poll(what = "`Invite members`, `Search` and `Mute` in the toolbar menu of alice's #team") { + exists(ADD_MEMBERS) && exists(SEARCH) && ntfLabel() == "Mute" + } + click(ADD_MEMBERS) + poll(what = "the invite members sheet listing bob") { ModalManager.end.hasModalsOpen() && exists(Ui.InviteMembers.Contact(contactOf("bob").contactId)) } + escape() + poll(what = "the invite members sheet to close") { !ModalManager.end.hasModalsOpen() } + cycleGroupNtfs("team") + backToChatList() + hostGroup(bob, "club", "alice") + joinGroupFromList(alice, bob, "club") + alice.chatList.open("club") + openBarMenu() + poll(what = "`Search` and `Mute` without `Invite members` or `Group link` in the toolbar menu of bob's #club") { + exists(SEARCH) && ntfLabel() == "Mute" && !exists(ADD_MEMBERS) && !seesText("Group link") && !seesText("Invite members") + } + click(SEARCH) + waitFor(Ui.Chat.Bar.part("search")) + } + + private fun UiScope.cycleGroupNtfs(group: String) { + openBarMenu() + poll(what = "`Mute` in the toolbar menu of #$group") { ntfLabel() == "Mute" } + click(NTF) + poll(what = "#$group muted to mentions only") { groupInfoOf(group).chatSettings.enableNtfs == MsgFilter.Mentions } + openBarMenu() + poll(what = "`Mute all` in the toolbar menu of #$group") { ntfLabel() == "Mute all" } + click(NTF) + poll(what = "#$group muted") { groupInfoOf(group).chatSettings.enableNtfs == MsgFilter.None } + openBarMenu() + poll(what = "`Unmute` in the toolbar menu of #$group") { ntfLabel() == "Unmute" } + click(NTF) + poll(what = "#$group unmuted") { groupInfoOf(group).chatSettings.enableNtfs == MsgFilter.All } + } + + private fun UiScope.openBarMenu() { + hideKeyboard() + click(Ui.Chat.BarMenu) + waitFor(NTF) + } + + private fun UiScope.ntfLabel(): String? = if (exists(NTF)) textsOf(NTF).firstOrNull() else null + + private companion object { + val TITLE = Ui.Chat.Bar.part("title") + val SEARCH = Ui.Chat.BarMenuItem("search") + val NTF = Ui.Chat.BarMenuItem("ntf") + val ADD_MEMBERS = Ui.Chat.BarMenuItem("addMembers") + const val FULL_NAME = "Robert Brown" + const val PQ_E2EE = "protected by quantum resistant e2e encryption" + const val GROUP_E2EE = "protected by end-to-end encryption with perfect forward secrecy" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ComposeTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ComposeTest.kt new file mode 100644 index 0000000000..b29c60b3ba --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ComposeTest.kt @@ -0,0 +1,208 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.PREVIEW_IMAGE_DATA +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.becomeBotWithCommands +import chat.simplex.app.ui.harness.commandShown +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.contactOf +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.groupMemberId +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.ownGroupOfThree +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertFalse +import org.junit.Test + +class ComposeTest : UiTest() { + @Test fun sendText_sendButton_peerReceives() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + poll(what = "the empty message field with the voice and live buttons and no send button") { + exists(Ui.Chat.Message) && editableText(Ui.Chat.Message).isNullOrEmpty() && + exists(Ui.Chat.Live) && seesDescription("Record voice message") && !exists(Ui.Chat.Send) + } + typeInto(Ui.Chat.Message, " ") + poll(what = "a disabled send button for a blank message") { exists(Ui.Chat.Send) && !isEnabled(Ui.Chat.Send) && !exists(Ui.Chat.Live) } + replaceText(Ui.Chat.Message, TEXT) + click(Ui.Chat.Send) + bob expectsMessage "alice> $TEXT" + poll(what = "the message field cleared after Send") { editableText(Ui.Chat.Message).isNullOrEmpty() } + chat.itemId(TEXT) + } + + @Test fun compose_clearTextButton_clears() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + typeInto(Ui.Chat.Message, "one\ntwo\nthree") + settle() + assertFalse("the clear button is shown for three lines", exists(Ui.Chat.ClearText)) + typeInto(Ui.Chat.Message, "\nfour") + poll(what = "the clear button for four lines") { exists(Ui.Chat.ClearText) } + hideKeyboard() + click(Ui.Chat.ClearText) + poll(what = "the message field empty and the clear button gone") { editableText(Ui.Chat.Message).isNullOrEmpty() && !exists(Ui.Chat.ClearText) } + chat.send("hello\nthere") + bob expectsMessage "alice> hello" + bob expects "there" + } + + @Test fun markdown_sent_rendersFormatted_peerGetsMarkers() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send(MARKDOWN) + bob expectsMessage "alice> $CLI_MARKDOWN" + val id = chat.itemId(MARKDOWN) + poll(what = "alice's bubble showing `bold italic strike code` without the markdown markers") { + textsOf(Ui.Chat.Item(id)).let { t -> t.any { "bold italic strike code" in it } && t.none { m -> MARKERS.any { it in m } } } + } + bob sends "/? @alice *bold*" + bob expectsMessage "alice> $CLI_MARKDOWN" + } + + @Test fun linkPreview_received_rendersCard() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + val pageUrl = "http://127.0.0.1/page" + val title = "Loopback page" + val description = "A page served on the loopback address" + bob sends "@alice see http://127.0.0.1/other" + val plain = chat.itemId("see http://127.0.0.1/other") + val aliceId = bob.contactId("alice") + bob sends """/_send @$aliceId json [{"msgContent": {"type": "link", "text": "$pageUrl", "preview": {"uri": "$pageUrl", "title": "$title", "description": "$description", "image": "$PREVIEW_IMAGE_DATA"}}}]""" + val card = chat.itemId(pageUrl) + poll(what = "bob's link message with the preview card: image, title, description and URL") { + "link preview image" in descriptionsOf(Ui.Chat.Item(card)) && textsOf(Ui.Chat.Item(card)).let { t -> title in t && description in t && pageUrl in t } + } + check("link preview image" !in descriptionsOf(Ui.Chat.Item(plain)) && title !in textsOf(Ui.Chat.Item(plain))) { "bob's plain text message with a link shows a preview card" } + } + + @Test fun mention_maxReached_showsLimit() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath"); val dan = cli("dan") + ownGroupOfThree(alice, bob, cath, dan) + typeInto(Ui.Chat.Message, "@b") + val bobRow = Ui.Chat.MentionMember(groupMemberId("bob")) + val cathRow = Ui.Chat.MentionMember(groupMemberId("cath")) + val danRow = Ui.Chat.MentionMember(groupMemberId("dan")) + val rows = listOf(bobRow, cathRow, danRow) + poll(what = "the mention picker listing bob and neither cath nor dan for `@b`") { exists(bobRow) && !exists(cathRow) && !exists(danRow) } + click(bobRow) + poll(what = "`@bob ` in the message field") { editableText(Ui.Chat.Message) == "@bob " } + typeInto(Ui.Chat.Message, "@") + poll(what = "bob, cath and dan offered and enabled for the second mention, without the limit message") { rows.all { exists(it) && isEnabled(it) } && !seesText(MENTION_LIMIT) } + click(cathRow) + poll(what = "`@bob @cath ` in the message field") { editableText(Ui.Chat.Message) == "@bob @cath " } + typeInto(Ui.Chat.Message, "@") + poll(what = "bob, cath and dan offered and enabled for the third mention, without the limit message") { rows.all { exists(it) && isEnabled(it) } && !seesText(MENTION_LIMIT) } + click(danRow) + poll(what = "`@bob @cath @dan ` in the message field") { editableText(Ui.Chat.Message) == "@bob @cath @dan " } + typeInto(Ui.Chat.Message, "@") + poll(what = "the limit message with every member disabled for a fourth mention") { seesText(MENTION_LIMIT) && rows.all { exists(it) && !isEnabled(it) } } + } + + @Test fun mention_editAddsMention() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + val chat = ownGroup(alice, "team", bob, cath) + chat.send("hello") + bob.eventuallyMessage("#team alice> hello", CONNECT_TIMEOUT_MS) + cath.eventuallyMessage("#team alice> hello", CONNECT_TIMEOUT_MS) + val id = chat.itemId("hello") + check(chat.item(id)?.mentions.isNullOrEmpty()) { "`hello` mentions someone before the edit: ${chat.item(id)?.mentions}" } + hideKeyboard() + chat.menu("hello", "edit") + poll(what = "the edit bar with `hello` in the field") { editableText(Ui.Chat.Message) == "hello" && exists(described("Cancel")) } + replaceText(Ui.Chat.Message, "hello @b") + val bobId = groupMemberId("bob") + val cathId = groupMemberId("cath") + poll(what = "the mention picker listing bob and not cath for `@b` while editing") { exists(Ui.Chat.MentionMember(bobId)) && !exists(Ui.Chat.MentionMember(cathId)) } + click(Ui.Chat.MentionMember(bobId)) + poll(what = "`hello @bob ` in the message field") { editableText(Ui.Chat.Message) == "hello @bob " } + typeInto(Ui.Chat.Message, "there") + chat.sendComposed() + bob.eventuallyMessage("#team alice> [edited] hello @bob there", CONNECT_TIMEOUT_MS) + cath.eventuallyMessage("#team alice> [edited] hello @bob there", CONNECT_TIMEOUT_MS) + poll(what = "alice's `hello` edited into `hello @bob there`, mentioning bob") { + chat.item(id)?.let { it.content.text == "hello @bob there" && it.mentions?.get("bob")?.memberRef?.groupMemberId == bobId } == true && + textsOf(Ui.Chat.Item(id)).any { "hello @bob there" in it } + } + } + + @Test fun commandsMenu_slash_showsBotCommands() = uiTest { + val alice = app("alice"); val bob = cli("bob") + bob.becomeBotWithCommands() + connect(bob) + alice.chatList.open("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's bot commands in alice's contact") { contactOf("bob").profile.preferences?.commands?.isNotEmpty() == true } + waitFor(Ui.Chat.CommandsButton) + check(!exists(Ui.Chat.Command("help")) && !exists(MORE)) { "the commands menu is open before `//` is clicked" } + hideKeyboard() + click(Ui.Chat.CommandsButton) + poll(what = "the commands menu with Help, Order and More, without the commands inside More") { + commandShown("help", "Help") && commandShown("order", "Order") && exists(MORE) && "More" in textsOf(MORE) && + !exists(Ui.Chat.Command("status")) && !exists(Ui.Chat.CommandsBack) + } + click(MORE) + poll(what = "the More submenu with Status and Cost under a back row labelled More") { + commandShown("status", "Status") && commandShown("cost", "Cost") && exists(Ui.Chat.CommandsBack) && "More" in textsOf(Ui.Chat.CommandsBack) && + !exists(Ui.Chat.Command("help")) && !exists(MORE) + } + click(Ui.Chat.CommandsBack) + poll(what = "the top commands back after the back row") { + exists(Ui.Chat.Command("help")) && exists(Ui.Chat.Command("order")) && exists(MORE) && !exists(Ui.Chat.Command("status")) && !exists(Ui.Chat.CommandsBack) + } + click(Ui.Chat.CommandsButton) + poll(what = "the commands menu closed by `//`") { !exists(Ui.Chat.Command("help")) && !exists(MORE) } + typeInto(Ui.Chat.Message, "/or") + poll(what = "only Order listed for `/or`") { + exists(Ui.Chat.Command("order")) && !exists(Ui.Chat.Command("help")) && !exists(MORE) && !exists(Ui.Chat.Command("status")) && !exists(Ui.Chat.Command("cost")) + } + replaceText(Ui.Chat.Message, "/st") + poll(what = "only Status from the More submenu listed for `/st`") { + exists(Ui.Chat.Command("status")) && !exists(Ui.Chat.Command("cost")) && !exists(Ui.Chat.Command("order")) && !exists(Ui.Chat.Command("help")) && !exists(Ui.Chat.CommandsBack) + } + replaceText(Ui.Chat.Message, "/") + poll(what = "the top commands listed for `/`") { + exists(Ui.Chat.Command("help")) && exists(Ui.Chat.Command("order")) && exists(MORE) && !exists(Ui.Chat.Command("status")) + } + } + + @Test fun commandsMenu_selectCommand_sendsToBot() = uiTest { + val alice = app("alice"); val bob = cli("bob") + bob.becomeBotWithCommands() + connect(bob) + val chat = alice.chatList.open("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's bot commands in alice's contact") { contactOf("bob").profile.preferences?.commands?.isNotEmpty() == true } + hideKeyboard() + click(Ui.Chat.CommandsButton) + click(Ui.Chat.Command("order")) + poll(what = "`/order ` in the message field and the commands menu closed") { + editableText(Ui.Chat.Message) == "/order " && !exists(Ui.Chat.Command("order")) && !exists(Ui.Chat.Command("help")) + } + replaceText(Ui.Chat.Message, "/order 7") + chat.sendComposed() + bob.expectsMessage("alice> /order 7", CONNECT_TIMEOUT_MS) + hideKeyboard() + click(Ui.Chat.CommandsButton) + click(Ui.Chat.Command("help")) + poll(what = "the message field empty and the commands menu closed after Help") { + editableText(Ui.Chat.Message).isNullOrEmpty() && !exists(Ui.Chat.Command("help")) + } + bob.expectsMessage("alice> /help", CONNECT_TIMEOUT_MS) + chat.itemId("/help") + } + + private companion object { + const val TEXT = "hello there 🙂" + const val MARKDOWN = "*bold* _italic_ ~strike~ `code`" + const val CLI_MARKDOWN = "bold italic strike `code`" + val MARKERS = listOf("*bold*", "_italic_", "~strike~", "`code`") + const val MENTION_LIMIT = "You can mention up to 3 members per message!" + val MORE = Ui.Chat.CommandMenu("More") + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ContextMenuTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ContextMenuTest.kt new file mode 100644 index 0000000000..8452ad5b9d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ContextMenuTest.kt @@ -0,0 +1,49 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.sendsFileTo +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Test + +private val ACTIONS = listOf("reply", "copy", "download", "edit", "forward", "info", "delete", "reveal", "hide", "moderate", "report", "select") + +class ContextMenuTest : UiTest() { + @Test fun contextMenu_itemsMatrix_byItemType() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice copy me" + bob.sendsFileTo("alice", bob.createFile("offer.txt", "offered text"), 1, "offer.txt") + bob sends "@alice to be deleted" + bob sends "\\@alice to be deleted" + bob expects "message marked deleted" + val chat = alice.chatList.open("bob") + val text = chat.itemId("copy me") + val file = chat.itemId("bob's offer of offer.txt") { it.file?.fileName == "offer.txt" } + val deleted = chat.itemId("bob's `to be deleted` marked deleted") { it.content.text == "to be deleted" && it.meta.itemDeleted != null } + setClipboardText("nothing copied yet") + poll(what = "the clipboard holding the placeholder text") { clipboardText() == "nothing copied yet" } + assertEquals("menu of bob's text", setOf("reply", "share", "copy", "forward", "info", "delete", "select"), menuOf(text)) + click(Ui.Chat.ItemMenu(text, "copy")) + poll(what = "`copy me` on the clipboard after Copy") { clipboardText() == "copy me" } + assertEquals("menu of bob's file offer", setOf("reply", "download", "info", "delete", "select"), menuOf(file)) + closeBySelecting(file) + assertEquals("menu of bob's message marked deleted", setOf("reveal", "info", "delete", "select"), menuOf(deleted)) + closeBySelecting(deleted) + } + + private fun UiScope.menuOf(id: Long): Set { + rightClick(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "select")) + return ACTIONS.filter { exists(Ui.Chat.ItemMenu(id, it)) }.toSet() + listOfNotNull("share".takeIf { seesText("Share") }) + } + + private fun UiScope.closeBySelecting(id: Long) { + click(Ui.Chat.ItemMenu(id, "select")) + waitFor(Ui.Chat.Selection.part("title")) + escape() + poll(what = "selection mode to end") { !exists(Ui.Chat.Selection.part("title")) } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/CopyShareTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/CopyShareTest.kt new file mode 100644 index 0000000000..af3576aeb0 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/CopyShareTest.kt @@ -0,0 +1,47 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.itemFileOf +import chat.simplex.app.ui.harness.sendsFileTo +import chat.simplex.app.ui.harness.storedBytesOf +import chat.simplex.common.model.CIFileStatus +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class CopyShareTest : UiTest() { + @Test fun save_encryptedFile_decrypts() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob.sendsFileTo("alice", bob.createFile(NOTES_NAME, NOTES), 1, NOTES_NAME) + val target = File(exchangeDir(), "saved-$NOTES_NAME").apply { delete() } + val chat = alice.chatList.open("bob") + val id = chat.itemId("the item of $NOTES_NAME") { it.file?.fileName == NOTES_NAME } + rightClick(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "download")) + check(!exists(Ui.Chat.ItemMenu(id, "copy")) && !exists(Ui.Chat.ItemMenu(id, "share")) && !exists(Ui.Chat.ItemMenu(id, "save"))) { + "the menu of a file that is not downloaded offers Copy, Share or Save" + } + click(Ui.Chat.ItemMenu(id, "download")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "$NOTES_NAME received") { itemFileOf(id)?.fileStatus is CIFileStatus.RcvComplete } + val stored = itemFileOf(id)!! + check(stored.fileSource?.cryptoArgs != null && !storedBytesOf(stored).contentEquals(NOTES.toByteArray())) { "$NOTES_NAME is stored unencrypted" } + rightClick(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "save")) + check(exists(Ui.Chat.ItemMenu(id, "copy")) && exists(Ui.Chat.ItemMenu(id, "share")) && !exists(Ui.Chat.ItemMenu(id, "download"))) { + "the menu of the downloaded file has no Copy or Share, or still offers Download" + } + check(!target.exists()) { "${target.name} exists before Save" } + saveFileAs(target) { click(Ui.Chat.ItemMenu(id, "save")) } + poll(what = "the decrypted $NOTES_NAME written to ${target.name}") { target.length() == NOTES.length.toLong() } + assertEquals(NOTES, target.readText()) + } + + private companion object { + const val NOTES_NAME = "save-notes.md" + const val NOTES = "Shopping list from bob: apples and pears" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/DeleteMessageTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/DeleteMessageTest.kt index e8eb361e8b..2799bc1aac 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/DeleteMessageTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/DeleteMessageTest.kt @@ -1,8 +1,17 @@ package chat.simplex.app.ui.chat +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS import chat.simplex.app.ui.harness.UiTest import chat.simplex.app.ui.harness.connect +import chat.simplex.common.model.ChatFeature +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.ContactFeatureAllowed +import chat.simplex.common.model.contactFeaturesAllowedToPrefs +import chat.simplex.common.model.contactUserPrefsToFeaturesAllowed +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertFalse import org.junit.Test @@ -21,4 +30,101 @@ class DeleteMessageTest : UiTest() { chat.send("ping") bob expectsMessage "alice> ping" } + + @Test fun delete_forEveryone_fullDeletion_peerSeesDeleted() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("one") + bob expectsMessage "alice> one" + val one = chat.itemId("one") + chat.menu("one", "delete") + poll(what = "`Delete message?` saying it will be marked for deletion, with both buttons") { + seesText("Delete message?") && seesText(MARK_ONE) && exists(FOR_ME) && exists(FOR_EVERYONE) + } + click(FOR_ME) + poll(what = "`one` removed from alice's chat") { chat.item(one) == null && !exists(Ui.Chat.Item(one)) } + bob sends "/set delete @alice always" + bob expects "you updated preferences for alice:" + bob expects "Full deletion: enabled for contact (you allow: always, contact allows: no)" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's full-deletion consent reaching alice") { alice.chatList.chat("bob").chatInfo.featureEnabled(ChatFeature.FullDelete) } + chat.send("two") + bob expectsMessage "alice> two" + val two = chat.itemId("two") + chat.menu("two", "delete") + poll(what = "`Delete message?` saying it cannot be undone") { seesText(FULL_ONE) && !seesText(MARK_ONE) && exists(FOR_EVERYONE) } + click(FOR_EVERYONE) + bob expectsMessage "alice> [deleted] two" + poll(what = "`two` removed, not marked deleted") { chat.item(two) == null && !exists(Ui.Chat.Item(two)) } + chat.send("three") + bob expectsMessage "alice> three" + chat.send("four") + bob expectsMessage "alice> four" + val three = chat.itemId("three"); val four = chat.itemId("four") + hideKeyboard() + chat.select("three") + chat.toggle(four) + poll(what = "Selected 2") { chat.selectionTitle() == "Selected 2" } + click(SEL_DELETE) + poll(what = "`Delete 2 messages?` saying they cannot be undone, with For everyone") { seesText("Delete 2 messages?") && seesText(FULL_MANY) && exists(FOR_EVERYONE) } + click(FOR_EVERYONE) + bob expectsMessage "alice> [deleted] three" + bob expectsMessage "alice> [deleted] four" + poll(what = "both removed and selection ended") { chat.item(three) == null && chat.item(four) == null && !exists(SEL_TITLE) } + } + + @Test fun markedDeleted_received_revealHide() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice keep" + bob sends "@alice one" + bob sends "@alice two" + val chat = alice.chatList.open("bob") + val keep = chat.itemId("keep"); val one = chat.itemId("one"); val two = chat.itemId("two") + check(textsOf(Ui.Chat.Item(one)).let { t -> t.any { "one" in it } && "marked deleted" !in t }) { "`one` is not shown as sent before bob deletes it" } + bob sends "\\@alice one" + bob expects "message marked deleted" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`one` shown as `marked deleted` without its text") { + textsOf(Ui.Chat.Item(one)).let { t -> "marked deleted" in t && t.none { "one" in it } } + } + bob sends "\\@alice two" + bob expects "message marked deleted" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`2 messages marked deleted` on one row") { + "2 messages marked deleted" in textsOf(Ui.Chat.Item(two)) && !exists(Ui.Chat.Item(one)) + } + check(textsOf(Ui.Chat.Item(keep)).any { "keep" in it }) { "`keep` changed after bob's deletions" } + chat.menu("two", "reveal") + poll(what = "both texts revealed") { textsOf(Ui.Chat.Item(one)).any { "one" in it } && textsOf(Ui.Chat.Item(two)).any { "two" in it } } + chat.menu("two", "hide") + poll(what = "collapsed again") { "2 messages marked deleted" in textsOf(Ui.Chat.Item(two)) && !exists(Ui.Chat.Item(one)) } + chat.menu("two", "select") + poll(what = "the collapsed range selected as 2") { chat.selectionTitle() == "Selected 2" } + escape() + poll(what = "selection mode to end") { !exists(SEL_TITLE) } + allowFullDeletionViaApi("bob") + bob expects "alice updated preferences for you:" + bob expects "Full deletion: enabled for you (you allow: default (no), contact allows: always)" + bob sends "@alice three" + val three = chat.itemId("three") + bob sends "\\@alice three" + bob expects "message deleted" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`three` removed from alice's chat") { chat.item(three) == null && !exists(Ui.Chat.Item(three)) } + check(exists(Ui.Chat.Item(keep)) && "2 messages marked deleted" in textsOf(Ui.Chat.Item(two))) { "`keep` or the collapsed row changed after the full deletion" } + } + + private fun allowFullDeletionViaApi(contactName: String) { + val contact = (chatModel.chats.value.first { it.chatInfo.chatViewName == contactName }.chatInfo as ChatInfo.Direct).contact + val prefs = contactFeaturesAllowedToPrefs(contactUserPrefsToFeaturesAllowed(contact.mergedPreferences).copy(fullDelete = ContactFeatureAllowed.Always)) + runBlocking { chatController.apiSetContactPrefs(null, contact.contactId, prefs) } ?: error("apiSetContactPrefs($contactName) failed") + } + + private companion object { + val FOR_ME = Ui.Alert.Option(Ui.AlertSource.Shared, "forMe") + val FOR_EVERYONE = Ui.Alert.Option(Ui.AlertSource.Shared, "forEveryone") + val SEL_TITLE = Ui.Chat.Selection.part("title") + val SEL_DELETE = Ui.Chat.Selection.part("delete") + const val MARK_ONE = "Message will be marked for deletion. The recipient(s) will be able to reveal this message." + const val FULL_ONE = "Message will be deleted - this cannot be undone!" + const val FULL_MANY = "Messages will be deleted - this cannot be undone!" + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/DraftTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/DraftTest.kt new file mode 100644 index 0000000000..3c13e51c20 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/DraftTest.kt @@ -0,0 +1,88 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitRowText +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.switchAwayFromChat +import chat.simplex.app.ui.harness.visitNotesAndBack +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class DraftTest : UiTest() { + @Test fun draft_onlyLastChatKept() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + val bobId = alice.chatList.chatId("bob"); val cathId = alice.chatList.chatId("cath") + alice.chatList.open("bob") + typeInto(Ui.Chat.Message, "for bob") + backToChatList() + alice.chatList.open("cath") + poll(what = "cath's field empty, bob's draft not carried over") { chatModel.chatId.value == cathId && editableText(Ui.Chat.Message).isNullOrEmpty() } + typeInto(Ui.Chat.Message, "for cath") + switchAwayFromChat(alice) + poll(what = "a draft on cath's row only") { + alice.chatList.rowTexts("cath").any { it.endsWith(" for cath") } && alice.chatList.rowTexts("bob").none { it.endsWith(" for bob") } + } + alice.chatList.open("bob") + poll(what = "bob's field empty, only the last draft kept") { chatModel.chatId.value == bobId && editableText(Ui.Chat.Message).isNullOrEmpty() } + backToChatList() + val chat = alice.chatList.open("cath") + poll(what = "cath's draft restored") { chatModel.chatId.value == cathId && editableText(Ui.Chat.Message) == "for cath" } + chat.sendComposed() + cath.eventuallyMessage("alice> for cath", CONNECT_TIMEOUT_MS) + poll(what = "cath's row with the sent text and no draft") { + alice.chatList.rowTexts("cath").let { "for cath" in it && it.none { t -> t != "for cath" && t.endsWith(" for cath") } } + } + } + + @Test fun draft_withQuote_restored() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice question" + awaitRowText(alice, "bob", "question") + val chat = alice.chatList.open("bob") + chat.send("hello") + bob expectsMessage "alice> hello" + check(!seesDescription("Context icon")) { "a context bar is shown before Reply" } + chat.menu("question", "reply") + typeInto(Ui.Chat.Message, "answer") + visitNotesAndBack(alice, "bob") + poll(what = "the reply draft restored") { editableText(Ui.Chat.Message) == "answer" && seesDescription("Context icon") } + chat.sendComposed() + bob expectsMessage "alice> > question" + bob expects " answer" + chat.menu("hello", "edit") + poll(what = "the edited text in the field") { editableText(Ui.Chat.Message) == "hello" } + replaceText(Ui.Chat.Message, "hello world") + visitNotesAndBack(alice, "bob") + poll(what = "the edit draft restored") { editableText(Ui.Chat.Message) == "hello world" && seesDescription("Context icon") } + chat.sendComposed() + bob expectsMessage "alice> [edited] hello world" + } + + @Test fun draft_clearedWhenSendDisabled() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + val bobId = alice.chatList.chatId("bob"); val cathId = alice.chatList.chatId("cath") + alice.chatList.open("bob") + typeInto(Ui.Chat.Message, "for bob") + bob sends "/d alice" + bob expects "alice: contact is deleted" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the field cleared once sending is impossible") { + !alice.chatList.chat("bob").chatInfo.sendMsgEnabled && exists(Ui.Chat.Message) && editableText(Ui.Chat.Message).isNullOrEmpty() + } + backToChatList() + alice.chatList.open("cath") + poll(what = "no draft on bob's row") { chatModel.chatId.value == cathId && alice.chatList.rowTexts("bob").none { it.endsWith(" for bob") } } + typeInto(Ui.Chat.Message, "for cath") + backToChatList() + alice.chatList.open("bob") + poll(what = "bob's chat open") { chatModel.chatId.value == bobId } + backToChatList() + alice.chatList.open("cath") + poll(what = "cath's draft kept through the visit to bob's chat") { chatModel.chatId.value == cathId && editableText(Ui.Chat.Message) == "for cath" } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/EditMessageTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/EditMessageTest.kt index 1f9ba1971d..7899fd527d 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/EditMessageTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/EditMessageTest.kt @@ -1,20 +1,98 @@ package chat.simplex.app.ui.chat +import chat.simplex.app.ui.harness.UiScope import chat.simplex.app.ui.harness.UiTest import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class EditMessageTest : UiTest() { + @Test fun edit_menu_updatesPeer() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("hello") + bob expectsMessage "alice> hello" + val id = chat.itemId("hello") + chat.itemInfo("hello").run { + val h = history() + check(h.count { it == "(current)" } == 1 && h.indexOfFirst { it.startsWith("hello") } in 0 until h.indexOf("(current)") && h.none { "hey" in it }) { "unedited history: $h" } + close() + } + waitFor(Ui.Chat.Message) + assertFalse("edit bar before Edit", exists(described("Cancel"))) + chat.menu("hello", "edit") + poll(what = "the edit bar with `hello` in the message field") { editableText(Ui.Chat.Message) == "hello" && exists(described("Cancel")) } + hideKeyboard() + click(described("Cancel")) + poll(what = "the edit bar gone and the field empty") { !exists(described("Cancel")) && editableText(Ui.Chat.Message).isNullOrEmpty() } + chat.menu("hello", "edit") + poll(what = "`hello` in the message field") { editableText(Ui.Chat.Message) == "hello" } + chat.replaceAndSend("hello") + chat.menu("hello", "edit") + chat.replaceAndSend("hey 👋") + bob expectsMessage "alice> [edited] hey 👋" + poll(what = "the same bubble showing `hey 👋`, not `hello`, edited in the model") { + chat.item(id)?.let { it.content.text == "hey 👋" && it.meta.itemEdited } == true && + textsOf(Ui.Chat.Item(id)).let { t -> t.any { "hey 👋" in it } && t.none { "hello" in it } } + } + chat.itemInfo("hey 👋").run { + val h = history() + val newest = h.indexOfFirst { it.startsWith("hey 👋") } + val current = h.indexOf("(current)") + val old = h.indexOfFirst { it.startsWith("hello") } + check(newest in 0 until current && current < old && h.count { it == "(current)" } == 1) { "edited history: $h" } + close() + } + } + + @Test fun edit_notOffered_forReceivedOrForwarded() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice theirs" + val chat = alice.chatList.open("bob") + val received = chat.itemId("theirs") + chat.send("mine") + bob expectsMessage "alice> mine" + val mine = chat.itemId("mine") + chat.forwardTo("theirs", "bob") + var forwarded = 0L + poll(what = "alice's forwarded copy of `theirs`") { + forwarded = chatModel.chatsContext.chatItems.value.lastOrNull { it.content.text == "theirs" && it.meta.itemForwarded != null }?.id ?: 0L + forwarded != 0L && exists(Ui.Chat.Item(forwarded)) + } + hideKeyboard() + assertTrue("Edit not offered on alice's own message", editOffered(mine)) + assertFalse("Edit offered on bob's message", editOffered(received)) + assertFalse("Edit offered on the forwarded message", editOffered(forwarded)) + } + @Test fun edit_received_updatesBubble() = uiTest { val alice = app("alice"); val bob = cli("bob") connect(bob) bob sends "@alice hello" val chat = alice.chatList.open("bob") val id = chat.itemId("hello") + check(textsOf(Ui.Chat.Item(id)).any { "hello" in it }) { "bob's bubble does not show `hello` before the edit" } bob sends "!@alice hey" - poll(what = "the edited bubble") { - chat.item(id)?.let { it.content.text == "hey" && it.meta.itemEdited } == true && textsOf(Ui.Chat.Item(id)).any { "hey" in it } + poll(what = "the edited bubble showing `hey`, not `hello`") { + chat.item(id)?.let { it.content.text == "hey" && it.meta.itemEdited } == true && + textsOf(Ui.Chat.Item(id)).let { t -> t.any { "hey" in it } && t.none { "hello" in it } } } } + + private fun UiScope.editOffered(id: Long): Boolean { + rightClick(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "select")) + val offered = exists(Ui.Chat.ItemMenu(id, "edit")) + click(Ui.Chat.ItemMenu(id, "select")) + waitFor(Ui.Chat.Selection.part("title")) + escape() + poll(what = "selection mode to end") { !exists(Ui.Chat.Selection.part("title")) } + return offered + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ForwardTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ForwardTest.kt index 301b4dcf93..4015df3495 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ForwardTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ForwardTest.kt @@ -1,11 +1,27 @@ package chat.simplex.app.ui.chat import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.UiScope import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitRowText +import chat.simplex.app.ui.harness.backToChatList import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.connectPeers +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.newGroupsViaApi +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.receivesFile +import chat.simplex.app.ui.harness.sendsFileTo +import chat.simplex.common.model.CIFileStatus import chat.simplex.common.model.CIForwardedFrom +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui +import org.junit.Assert.assertFalse import org.junit.Test class ForwardTest : UiTest() { @@ -31,4 +47,257 @@ class ForwardTest : UiTest() { val id = chat.itemId("hey") poll(what = "the `saved from` / `bob` header") { textsOf(Ui.Chat.Item(id)).let { t -> t.any { "saved from" in it } && "bob" in t } } } + + @Test fun forward_acrossChatTypes_peersReceiveAndSourceOpens() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + ownGroup(alice, "team", cath) + backToChatList() + bob sends "@alice hey" + awaitRowText(alice, "bob", "hey") + val chat = alice.chatList.open("bob") + chat.forwardTo("hey", "team") + cath.eventuallyMessage("#team alice> -> forwarded", CONNECT_TIMEOUT_MS) + cath expects " hey" + cath sends "#team hello" + val teamId = alice.chatList.chatId("team"); val bobId = alice.chatList.chatId("bob") + poll(what = "team open after the forward") { chatModel.chatId.value == teamId } + val original = chat.itemId("hello") + chat.forwardTo("hello", "bob") + bob.eventuallyMessage("alice> -> forwarded", CONNECT_TIMEOUT_MS) + bob expects " hello" + val fwd = chat.itemId("hello") + poll(what = "the `forwarded` header without the source name") { + chatModel.chatId.value == bobId && textsOf(Ui.Chat.Item(fwd)).let { t -> "forwarded" in t && t.none { "forwarded from" in it || it == "team" } } + } + click(Ui.Chat.Item(fwd).part("goTo")) + poll(what = "team's chat opened at cath's `hello`") { chatModel.chatId.value == teamId && exists(Ui.Chat.Item(original)) } + backToChatList() + val notesId = alice.chatList.chatId("Private notes") + val notes = alice.chatList.open("Private notes") + poll(what = "the notes folder open") { chatModel.chatId.value == notesId } + notes.send("memo") + notes.forwardTo("memo", "bob") + bob.expectsMessage("alice> memo", CONNECT_TIMEOUT_MS) + } + + @Test fun forward_withComment_sendsCommentQuoted() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + bob sends "@alice hey" + awaitRowText(alice, "bob", "hey") + val chat = alice.chatList.open("bob") + val cathId = alice.chatList.chatId("cath") + chat.menu("hey", "forward") + alice.chatList.shareTo("cath") + poll(what = "cath's chat with the forwarding bar") { chatModel.chatId.value == cathId && seesDescription("Context icon") && isEnabled(Ui.Chat.Send) } + click(described("Cancel")) + poll(what = "the forwarding bar gone and nothing forwarded") { + exists(Ui.Chat.Message) && !seesDescription("Context icon") && chatModel.chatsContext.chatItems.value.none { it.content.text == "hey" } + } + typeInto(Ui.Chat.Message, "unsent") + backToChatList() + alice.chatList.open("bob") + poll(what = "cath's row showing the draft") { alice.chatList.rowTexts("cath").any { it.endsWith(" unsent") } } + chat.menu("hey", "forward") + alice.chatList.shareTo("cath") + poll(what = "the forwarding bar with an empty field, the draft hidden") { + chatModel.chatId.value == cathId && seesDescription("Context icon") && editableText(Ui.Chat.Message).isNullOrEmpty() + } + typeInto(Ui.Chat.Message, "see this") + poll(what = "the send button to be enabled") { isEnabled(Ui.Chat.Send) } + pressEnter(Ui.Chat.Message) + cath.expectsMessage("alice> -> forwarded", CONNECT_TIMEOUT_MS) + cath expects " hey" + cath expectsMessage "alice> >> hey" + cath expects " see this" + poll(what = "the draft back after the forward") { editableText(Ui.Chat.Message) == "unsent" && !seesDescription("Context icon") } + } + + @Test fun forwarded_received_showsHeader() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connectPeers(bob, cath) + bob sends "@alice plain" + cath sends "@bob secret" + bob.eventuallyMessage("cath> secret", CONNECT_TIMEOUT_MS) + bob types "@alice <- @cath secret" + bob expectsMessage "@alice <- @cath" + bob expects " secret" + awaitRowText(alice, "bob", "secret") + val chat = alice.chatList.open("bob") + val plain = chat.itemId("plain") + val fwd = chat.itemId("secret") + check(textsOf(Ui.Chat.Item(plain)).let { t -> t.any { "plain" in it } && "forwarded" !in t }) { "an ordinary message shows a forwarded header" } + poll(what = "the `forwarded` header without the source name") { + textsOf(Ui.Chat.Item(fwd)).let { t -> "forwarded" in t && t.none { "forwarded from" in it || "cath" in it } } + } + assertFalse("a received forward offers a go-to-source arrow", exists(Ui.Chat.Item(fwd).part("goTo"))) + } + + @Test fun forwardPlan_filesNotAccepted_alertOptions() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + bob sends "@alice one" + bob.sendsFileTo("alice", bob.createFile("notes.md", NOTES), 1, "notes.md") + bob sends "@alice two" + bob sends "@alice three" + awaitRowText(alice, "bob", "three") + val chat = alice.chatList.open("bob") + val file = chat.itemId("the item of notes.md") { it.file?.fileName == "notes.md" } + val two = chat.itemId("two"); val three = chat.itemId("three") + check(chat.item(file)?.file?.fileStatus is CIFileStatus.RcvInvitation) { "notes.md was received before the forward: ${chat.item(file)?.file?.fileStatus}" } + chat.select("one") + chat.toggle(file); chat.toggle(two); chat.toggle(three) + poll(what = "`Selected 4`") { chat.selectionTitle() == "Selected 4" } + chat.forwardSelected() + poll(what = "the plan alert for 3 messages and 1 file with Forward, Download and Cancel") { + seesText("Forward 3 message(s)?") && seesText("1 file(s) were not downloaded.", substring = true) && + seesText("Forward messages without files?", substring = true) && exists(FORWARD) && exists(DOWNLOAD) && seesText("Cancel") + } + click(FORWARD) + poll(what = "the destination list for several messages") { seesText("Forward messages…") } + alice.chatList.shareTo("cath") + val cathId = alice.chatList.chatId("cath") + poll(what = "cath's chat with `Forwarding 3 messages`") { chatModel.chatId.value == cathId && seesText("Forwarding 3 messages") && isEnabled(Ui.Chat.Send) } + bob sends "\\@alice three" + bob.eventually("message marked deleted") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's deletion processed by alice") { "marked deleted" in alice.chatList.rowTexts("bob") } + click(Ui.Chat.Send) + cath.expectsMessage("alice> -> forwarded", CONNECT_TIMEOUT_MS) + cath expects " one" + cath expectsMessage "alice> -> forwarded" + cath expects " two" + poll(what = "the deleted-after-selection alert") { seesText("1 messages not forwarded") && seesText("Messages were deleted after you selected them.") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + chat.send("end") + cath expectsMessage "alice> end" + } + + @Test fun forwardPlan_downloadButton_receivesFiles() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + bob sends "@alice kept" + bob sends "@alice gone" + bob sends "\\@alice gone" + bob.eventually("message marked deleted") + bob.sendsFileTo("alice", bob.createFile("notes.md", NOTES), 1, "notes.md") + awaitRowText(alice, "bob", "notes.md") + val chat = alice.chatList.open("bob") + val bobId = alice.chatList.chatId("bob") + val kept = chat.itemId("kept"); val gone = chat.itemId("gone") + val file = chat.itemId("the item of notes.md") { it.file?.fileName == "notes.md" } + menuCheck(chat, kept, "the menu of a message has no Forward") { exists(Ui.Chat.ItemMenu(kept, "forward")) } + menuCheck(chat, gone, "the menu of a deleted message offers Forward") { !exists(Ui.Chat.ItemMenu(gone, "forward")) } + rightClick(Ui.Chat.Item(file)) + waitFor(Ui.Chat.ItemMenu(file, "select")) + check(exists(Ui.Chat.ItemMenu(file, "download")) && !exists(Ui.Chat.ItemMenu(file, "forward"))) { "the menu of a file not downloaded has no Download or offers Forward" } + click(Ui.Chat.ItemMenu(file, "select")) + poll(what = "`Selected 1`") { chat.selectionTitle() == "Selected 1" } + chat.forwardSelected() + poll(what = "`Nothing to forward!` with Download and no Forward") { + seesText("Nothing to forward!") && seesText("1 file(s) were not downloaded.") && !seesText("Forward messages without files?", substring = true) && + exists(DOWNLOAD) && !exists(FORWARD) + } + click(DOWNLOAD) + poll(what = "the alert closed and no destination list") { !seesText("Nothing to forward!") && !seesText("Forward message…") && chatModel.chatId.value == bobId } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "notes.md received") { chat.item(file)?.file?.fileStatus is CIFileStatus.RcvComplete } + escape() + poll(what = "selection mode to end") { chat.selectionTitle() == null } + chat.forwardItemTo(file, "cath") + cath.expectsMessage("alice> -> forwarded", CONNECT_TIMEOUT_MS) + cath expects " notes_1.md" + cath expectsMessage "alice> sends file notes_1.md (${NOTES.length} bytes / ${NOTES.length} bytes)" + cath expects "use /fr 1 [/ | ] to receive it" + cath.receivesFile(1, "notes_1.md", "alice") + } + + @Test fun forward_destinationProhibitsContent_alert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + bob sends "/img @alice $FIXTURE_JPG" + bob expectsMessage "/f @alice $FIXTURE_JPG" + bob expects "use /fc 1 to cancel sending" + bob.eventually("completed uploading file 1 (test.jpg) for alice", CONNECT_TIMEOUT_MS) + backToChatList() + val chat = alice.chatList.open("bob") + val teamId = alice.chatList.chatId("team") + val img = chat.itemId("bob's image") { it.content.msgContent is MsgContent.MCImage } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the image received") { chat.item(img)?.file?.fileStatus is CIFileStatus.RcvComplete } + rightClick(Ui.Chat.Item(img)) + click(Ui.Chat.ItemMenu(img, "forward")) + alice.chatList.shareTo("team") + poll(what = "team open with the forwarding bar while files are allowed") { chatModel.chatId.value == teamId && seesDescription("Context icon") && isEnabled(Ui.Chat.Send) } + click(described("Cancel")) + poll(what = "the forwarding bar gone") { exists(Ui.Chat.Message) && !seesDescription("Context icon") } + bob sends "/set files #team off" + bob expects "updated group preferences:" + bob expects "Files and media: off" + chat.itemId("`Files and media: off` in team") { it.content.text.startsWith("Files and media: off") } + backToChatList() + alice.chatList.open("bob") + rightClick(Ui.Chat.Item(img)) + click(Ui.Chat.ItemMenu(img, "forward")) + alice.chatList.shareTo("team") + poll(what = "`Cannot send message` and no chat opened") { + seesText("Cannot send message") && seesText("Selected chat preferences prohibit this message.") && chatModel.chatId.value == null + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "still in the destination list") { !seesText("Cannot send message") && seesText("Forward message…") && chatModel.chatId.value == null } + alice.chatList.shareTo("Private notes") + val notesId = alice.chatList.chatId("Private notes") + poll(what = "notes with the forwarding bar") { chatModel.chatId.value == notesId && seesDescription("Context icon") && isEnabled(Ui.Chat.Send) } + click(Ui.Chat.Send) + val saved = chat.itemId("the image saved in notes") { it.content.msgContent is MsgContent.MCImage } + poll(what = "the `saved from` / `bob` header on the saved image") { textsOf(Ui.Chat.Item(saved)).let { t -> t.any { "saved from" in it } && "bob" in t } } + backToChatList() + val team = alice.chatList.open("team") + poll(what = "team open") { chatModel.chatId.value == teamId } + team.send("after") + bob expectsMessage "#team alice> after" + } + + @Test fun forward_searchDestinations_filtersList() = uiTest { + app("alice").chatList.row("Private notes") + val groups = listOf("bobcats", "jobs", "team", "club", "news", "work").take(SEARCH_MIN_CHATS - 1 - chatModel.chats.value.size) + check(groups.size >= 3) { "too many chats before the test: ${chatModel.chats.value.map { it.id }}" } + newGroupsViaApi(*groups.toTypedArray()) + val alice = restart() + poll(what = "the ${groups.size} groups loaded after the restart") { chatModel.chats.value.count { it.chatInfo is ChatInfo.Group } == groups.size } + val bob = cli("bob") + connect(bob) + bob sends "@alice hey" + awaitRowText(alice, "bob", "hey") + check(chatModel.chats.value.size == SEARCH_MIN_CHATS) { "$SEARCH_MIN_CHATS chats are needed for the search button, found ${chatModel.chats.value.map { it.id }}" } + alice.chatList.open("bob").menu("hey", "forward") + poll(what = "the destination list") { seesText("Forward message…") } + for (name in listOf("bob", "bobcats", "jobs", "team", "Private notes")) scrollTo(Ui.Share.Row(alice.chatList.chatId(name))) + alice.chatList.shareSearch("bob") + poll(what = "only bob and bobcats listed for `bob`") { + alice.chatList.shareShown("bob") && alice.chatList.shareShown("bobcats") && + !alice.chatList.shareShown("jobs") && !alice.chatList.shareShown("team") && !alice.chatList.shareShown("Private notes") + } + alice.chatList.shareTo("bobcats") + val bobcatsId = alice.chatList.chatId("bobcats") + poll(what = "bobcats opened with the forwarding bar") { chatModel.chatId.value == bobcatsId && seesDescription("Context icon") } + } + + private fun UiScope.menuCheck(chat: ChatScreen, id: Long, failure: String, cond: () -> Boolean) { + rightClick(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "select")) + check(cond()) { failure } + click(Ui.Chat.ItemMenu(id, "select")) + poll(what = "`Selected 1`") { chat.selectionTitle() == "Selected 1" } + escape() + poll(what = "selection mode to end") { chat.selectionTitle() == null } + } + + private companion object { + const val NOTES = "Shopping list from bob: apples and pears" + const val SEARCH_MIN_CHATS = 8 + const val FIXTURE_JPG = "tests/fixtures/test.jpg" + val FORWARD = Ui.Alert.Option(Ui.AlertSource.Shared, "forward") + val DOWNLOAD = Ui.Alert.Option(Ui.AlertSource.Shared, "download") + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ItemInfoTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ItemInfoTest.kt new file mode 100644 index 0000000000..45ecc20184 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ItemInfoTest.kt @@ -0,0 +1,246 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.confirmAlert +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.openItemInfo +import chat.simplex.app.ui.harness.openTimedMenu +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.common.model.CIDeleted +import chat.simplex.common.model.CIStatus +import chat.simplex.common.model.SndCIStatusProgress +import chat.simplex.common.model.localTimestamp +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertFalse +import org.junit.Test + +class ItemInfoTest : UiTest() { + @Test fun info_sent_showsSentAt() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("mine") + bob expectsMessage "alice> mine" + bob sends "@alice theirs" + val mine = chat.itemId("mine"); val theirs = chat.itemId("theirs") + chat.itemInfo("mine").run { + val sentAt = localTimestamp(chat.item(mine)!!.meta.itemTs) + check(row("Sent at") == sentAt) { "`Sent at` of alice's message: `${row("Sent at")}`, expected `$sentAt`" } + check(!seesText("Received message") && !seesText("Received at") && !seesText("Deleted at")) { "received or deleted rows in the info of alice's message" } + close() + } + chat.receivedItemInfo("theirs").run { + val meta = chat.item(theirs)!!.meta + check(row("Sent at") == localTimestamp(meta.itemTs) && row("Received at") == localTimestamp(meta.createdAt)) { + "`Sent at` / `Received at` of bob's message: `${row("Sent at")}` / `${row("Received at")}`" + } + check(!seesText("Sent message") && !seesText("Deleted at")) { "sent or deleted rows in the info of bob's message" } + close() + } + bob sends "\\@alice theirs" + bob expects "message marked deleted" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's message marked deleted") { + chat.item(theirs)?.meta?.itemDeleted is CIDeleted.Deleted && "marked deleted" in textsOf(Ui.Chat.Item(theirs)) + } + chat.receivedItemInfo("theirs").run { + val deletedAt = (chat.item(theirs)!!.meta.itemDeleted as CIDeleted.Deleted).deletedTs!!.let(::localTimestamp) + check(row("Deleted at") == deletedAt) { "`Deleted at` of bob's deleted message: `${row("Deleted at")}`, expected `$deletedAt`" } + close() + } + } + + @Test fun info_delivery_listsGroupMembers() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + val direct = alice.chatList.open("bob") + direct.send("just you") + bob expectsMessage "alice> just you" + direct.itemInfo("just you").run { + assertFalse("the info of a direct message has a Delivery tab", seesText("Delivery")) + close() + } + backToChatList() + val chat = ownGroup(alice, "team", bob, cath) + chat.send("hello team") + bob.eventuallyMessage("#team alice> hello team", CONNECT_TIMEOUT_MS) + cath.eventuallyMessage("#team alice> hello team", CONNECT_TIMEOUT_MS) + chat.itemInfo("hello team").run { + val bobId = memberId("bob"); val cathId = memberId("cath") + poll(what = "the Delivery tab listing bob and cath") { + seesText("Delivery") && listed(bobId, "bob") && listed(cathId, "cath") + } + close() + } + } + + @Test fun info_delivery_liveUpdatesOnReceipt() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + val chat = ownGroup(alice, "team", bob, cath) + cath.eventually("#team: member bob is connected", CONNECT_TIMEOUT_MS) + cath.stop() + chat.send("hi") + bob.eventuallyMessage("#team alice> hi", CONNECT_TIMEOUT_MS) + val hi = chat.itemId("hi") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`hi` received by bob only") { + (chat.item(hi)?.meta?.itemStatus as? CIStatus.SndRcvd)?.sndProgress == SndCIStatusProgress.Partial + } + chat.itemInfo("hi").run { + val bobRow = Ui.ItemInfo.Member(memberId("bob")); val cathRow = Ui.ItemInfo.Member(memberId("cath")) + poll(what = "the Delivery tab with bob `$RECEIVED` and cath `$SENT`") { + descriptionsOf(bobRow).let { RECEIVED in it && SENT !in it } && descriptionsOf(cathRow).let { SENT in it && RECEIVED !in it } + } + cath.start() + cath.eventuallyMessage("#team alice> hi", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "cath `$RECEIVED` in the open Delivery tab") { + descriptionsOf(cathRow).let { RECEIVED in it && SENT !in it } && RECEIVED in descriptionsOf(bobRow) + } + close() + } + } + + @Test fun info_quoteTab_showsQuoted() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice question" + val chat = alice.chatList.open("bob") + chat.itemId("question") + chat.send("plain") + bob expectsMessage "alice> plain" + chat.itemInfo("plain").run { + check(!exists(QUOTE_TAB) && !exists(Ui.ItemInfo.Quote)) { "the info of a message without a quote has an `In reply to` tab" } + close() + } + hideKeyboard() + chat.menu("question", "reply") + chat.send("answer") + bob expectsMessage "alice> > question" + bob expects " answer" + chat.itemInfo("answer").run { + poll(what = "the `In reply to` tab, with the history shown first") { exists(QUOTE_TAB) && !exists(Ui.ItemInfo.Quote) } + click(QUOTE_TAB) + poll(what = "the `In reply to` tab showing bob's `question`") { + exists(Ui.ItemInfo.Quote) && textsOf(Ui.ItemInfo.Quote).any { "question" in it } && seesText("In reply to") + } + close() + } + } + + @Test fun info_forwardedTab_showsSource() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + emptyGroup(alice, "team") + backToChatList() + bob sends "@alice hi" + val chat = alice.chatList.open("bob") + chat.itemId("hi") + hideKeyboard() + chat.forwardTo("hi", "team") + val teamId = alice.chatList.chatId("team") + poll(what = "team open with the forwarded `hi`") { chatModel.chatId.value == teamId && chat.itemTexts().contains("hi") } + hideKeyboard() + chat.itemInfo("hi").run { + poll(what = "the `Forwarded` tab, with the history shown first") { exists(FORWARDED_TAB) && !exists(Ui.ItemInfo.ForwardedFrom) } + click(FORWARDED_TAB) + poll(what = "`Forwarded from` bob, hidden from the recipients") { + exists(Ui.ItemInfo.ForwardedFrom) && "bob" in textsOf(Ui.ItemInfo.ForwardedFrom) && seesText("Forwarded from") && seesText(SOURCE_HIDDEN) + } + } + click(Ui.ItemInfo.ForwardedFrom) + val bobId = alice.chatList.chatId("bob") + poll(what = "bob's chat opened from the source row and the item info closed") { chatModel.chatId.value == bobId && !exists(Ui.ItemInfo.ForwardedFrom) } + hideKeyboard() + chat.forwardTo("hi", "Private notes") + val notesId = alice.chatList.chatId("Private notes") + poll(what = "the notes open with the saved `hi`") { chatModel.chatId.value == notesId && chat.itemTexts().contains("hi") } + hideKeyboard() + openItemInfo(chat.itemId("hi"), "Saved message").run { + poll(what = "the `Saved` tab, with the history shown first") { exists(SAVED_TAB) && !exists(Ui.ItemInfo.ForwardedFrom) } + click(SAVED_TAB) + poll(what = "`Saved from` bob, without the recipients line") { + exists(Ui.ItemInfo.ForwardedFrom) && "bob" in textsOf(Ui.ItemInfo.ForwardedFrom) && seesText("Saved from") && + !seesText("Forwarded from") && !seesText(SOURCE_HIDDEN) + } + close() + } + } + + @Test fun info_timed_showsDisappearsAt() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("plain") + bob expectsMessage "alice> plain" + chat.itemInfo("plain").run { + check(!seesText("Disappears at")) { "the info of a message without a timer shows `Disappears at`" } + close() + } + typeInto(Ui.Chat.Message, "timed") + openTimedMenu() + click(Ui.Chat.SendTimed.option(FIVE_MINUTES)) + bob.expectsMessage("alice> timed", CONNECT_TIMEOUT_MS) + val id = chat.itemId("timed") + var disappearsAt: String? = null + poll(what = "alice's `timed` with a 5-minute timer") { + disappearsAt = chat.item(id)?.meta?.itemTimed?.takeIf { it.ttl == FIVE_MINUTES }?.deleteAt?.let(::localTimestamp) + disappearsAt != null + } + chat.itemInfo("timed").run { + check(row("Disappears at") == disappearsAt) { "`Disappears at` of the timed message: `${row("Disappears at")}`, expected `$disappearsAt`" } + close() + } + } + + @Test fun info_moderated_showsModeratedAt() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = ownGroup(alice, "team", bob) + bob sends "#team spam" + val id = chat.itemId("spam") + chat.receivedItemInfo("spam").run { + check(!seesText("Moderated at") && !seesText("Deleted at")) { "the info of bob's message shows `Moderated at` or `Deleted at` before moderation" } + close() + } + hideKeyboard() + chat.menu("spam", "moderate") + confirmAlert("Delete member message?", "The message will be marked as moderated for all members.", "Delete") + bob.eventuallyMessage("#team bob> [marked deleted by alice] spam", CONNECT_TIMEOUT_MS) + var moderatedAt: String? = null + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `spam` moderated by alice") { + moderatedAt = (chat.item(id)?.meta?.itemDeleted as? CIDeleted.Moderated)?.deletedTs?.let(::localTimestamp) + moderatedAt != null && "moderated by alice" in textsOf(Ui.Chat.Item(id)) + } + openItemInfo(id, "Received message").run { + check(row("Moderated at") == moderatedAt && !seesText("Deleted at")) { "`Moderated at` of the moderated message: `${row("Moderated at")}`, expected `$moderatedAt`" } + close() + } + } + + private fun UiScope.listed(groupMemberId: Long, name: String): Boolean = + exists(Ui.ItemInfo.Member(groupMemberId)) && name in textsOf(Ui.ItemInfo.Member(groupMemberId)) + + private fun UiScope.memberId(name: String): Long { + var id: Long? = null + poll(what = "member `$name` in the model") { + id = chatModel.groupMembers.value.firstOrNull { it.memberProfile.displayName == name }?.groupMemberId + id != null + } + return id!! + } + + private companion object { + const val SENT = "sent" + const val RECEIVED = "received" + const val FIVE_MINUTES = 300 + const val SOURCE_HIDDEN = "Recipient(s) can't see who this message is from." + val QUOTE_TAB = described("In reply to") + val FORWARDED_TAB = described("Forwarded") + val SAVED_TAB = described("Saved") + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ItemStatusCompatTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ItemStatusCompatTest.kt new file mode 100644 index 0000000000..43d2fe1872 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ItemStatusCompatTest.kt @@ -0,0 +1,28 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.ownGroupWithTwo +import chat.simplex.common.model.CIStatus +import chat.simplex.common.model.MsgReceiptStatus +import chat.simplex.common.model.SndCIStatusProgress +import org.junit.Test + +class ItemStatusCompatTest : UiTest() { + @Test fun status_group_partialThenComplete() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = ownGroupWithTwo(alice, bob, cath) + cath.stop() + chat.send("hi") + bob.eventuallyMessage("#team alice> hi", CONNECT_TIMEOUT_MS) + val id = chat.itemId("hi") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`hi` delivered to bob only: delivered, partial") { + (chat.item(id)?.meta?.itemStatus as? CIStatus.SndRcvd)?.let { it.msgRcptStatus == MsgReceiptStatus.Ok && it.sndProgress == SndCIStatusProgress.Partial } == true + } + cath.start() + cath.eventuallyMessage("#team alice> hi", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`hi` delivered to bob and cath: delivered, complete") { + (chat.item(id)?.meta?.itemStatus as? CIStatus.SndRcvd)?.let { it.msgRcptStatus == MsgReceiptStatus.Ok && it.sndProgress == SndCIStatusProgress.Complete } == true + } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ItemStatusTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ItemStatusTest.kt new file mode 100644 index 0000000000..f68247996d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ItemStatusTest.kt @@ -0,0 +1,96 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.TestServers +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitConnectionDeletedOnServer +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.contactOf +import chat.simplex.common.model.CIStatus +import chat.simplex.common.model.SndError +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ItemStatusTest : UiTest() { + @Test fun status_authError_clickShowsDeliveryError() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("before") + bob.expectsMessage("alice> before", CONNECT_TIMEOUT_MS) + val before = chat.itemId("before") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`before` delivered to bob") { chat.item(before)?.meta?.itemStatus is CIStatus.SndRcvd } + click(Ui.Chat.Item(before)) + settle() + assertFalse("clicking a delivered message shows a delivery alert", seesText(DELIVERY_ERROR)) + bob sends "/d alice notify=off" + bob expects "alice: contact is deleted" + awaitConnectionDeletedOnServer(bob) + chat.send("after") + val after = chat.itemId("after") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`after` failed with an authorization error") { + (chat.item(after)?.meta?.itemStatus as? CIStatus.CISSndError)?.agentError is SndError.Auth + } + assertFalse("the delivery alert is shown before the failed message is clicked", seesText(DELIVERY_ERROR)) + click(Ui.Chat.Item(after)) + poll(what = "the `$DELIVERY_ERROR` alert explaining the authorization error") { seesText(DELIVERY_ERROR) && seesText(AUTH_ERROR_INFO) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the delivery error alert to close") { !seesText(DELIVERY_ERROR) } + } + + @Test fun status_quotaExceeded_warningAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice's contact with bob ready") { contactOf("bob").ready } + chat.send("drained") + bob.expectsMessage("alice> drained", CONNECT_TIMEOUT_MS) + val drained = chat.itemId("drained") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`drained` delivered to bob, so bob's queue is empty") { chat.item(drained)?.meta?.itemStatus is CIStatus.SndRcvd } + bob.stop() + chat.send("m1") + val first = chat.itemId("m1") + for (i in 2..QUOTA + 1) chat.send("m$i") + val last = chat.itemId("m${QUOTA + 1}") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the message over the queue capacity marked with a delivery warning") { + (chat.item(last)?.meta?.itemStatus as? CIStatus.SndWarning)?.agentError is SndError.Quota + } + assertFalse("a message within the queue capacity has a delivery warning", chat.item(first)?.meta?.itemStatus is CIStatus.SndWarning) + click(Ui.Chat.Item(last)) + poll(what = "the `$DELIVERY_WARNING` alert explaining the exceeded capacity") { seesText(DELIVERY_WARNING) && seesText(QUOTA_INFO) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the delivery warning alert to close") { !seesText(DELIVERY_WARNING) } + } + + @Test fun status_pending_whileServerDown() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice's contact with bob ready") { contactOf("bob").ready } + chat.send("before") + bob.expectsMessage("alice> before", CONNECT_TIMEOUT_MS) + val before = chat.itemId("before") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`before` sent with smp1 up") { chat.item(before)?.meta?.itemStatus.isSentOrDelivered() } + TestServers.stop("smp1") + chat.send("while down") + val pending = chat.itemId("while down") + poll(what = "the send of `while down` to return with smp1 down") { UiIdle.inFlight().isEmpty() } + chat.item(pending)?.meta?.itemStatus.let { assertTrue("`while down` is not pending with smp1 down: $it", it is CIStatus.SndNew) } + TestServers.start("smp1") + bob.eventuallyMessage("alice> while down", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`while down` sent once smp1 is back") { chat.item(pending)?.meta?.itemStatus.isSentOrDelivered() } + } + + private fun CIStatus?.isSentOrDelivered() = this is CIStatus.SndSent || this is CIStatus.SndRcvd + + private companion object { + const val QUOTA = 16 + const val DELIVERY_ERROR = "Message delivery error" + const val DELIVERY_WARNING = "Message delivery warning" + const val AUTH_ERROR_INFO = "Wrong key or unknown connection - most likely this connection is deleted." + const val QUOTA_INFO = "Capacity exceeded - recipient did not receive previously sent messages." + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/LiveMessageTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/LiveMessageTest.kt new file mode 100644 index 0000000000..13de30fc94 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/LiveMessageTest.kt @@ -0,0 +1,137 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.CliPeer +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertFalse +import org.junit.Test + +class LiveMessageTest : UiTest() { + @Test fun liveMessage_menuStart_alertConfirm_peerSeesStarted() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "/show on" + bob expects "ok" + val chat = alice.chatList.open("bob") + typeInto(Ui.Chat.Message, "hello") + rightClick(Ui.Chat.Send) + click(Ui.Chat.SendLive) + confirmLiveMessageAlert() + bob.expectsLiveStarted("hello") + val id = chat.itemId("hello") + poll(what = "alice's `hello` marked LIVE") { "LIVE" in textsOf(Ui.Chat.Item(id)) } + typeInto(Ui.Chat.Message, " there,") + bob.expectsMessage("alice> [LIVE] hello there,", CONNECT_TIMEOUT_MS) + poll(what = "alice's bubble updated in place to `hello there,`, still LIVE") { + chat.item(id)?.content?.text == "hello there," && textsOf(Ui.Chat.Item(id)).let { t -> "LIVE" in t && t.any { "hello there," in it } } + } + typeInto(Ui.Chat.Message, " bob") + click(Ui.Chat.Send) + bob.eventuallyMessage("alice> [LIVE ended] hello there, bob", CONNECT_TIMEOUT_MS) + poll(what = "the field cleared and alice's bubble showing `hello there, bob` without LIVE") { + editableText(Ui.Chat.Message).isNullOrEmpty() && + chat.item(id)?.let { it.content.text == "hello there, bob" && !it.meta.isLive } == true && "LIVE" !in textsOf(Ui.Chat.Item(id)) + } + chat.send("bye") + bob expectsMessage "alice> bye" + } + + @KnownAppIssue("15.5 item 13", "[LIVE started] use /show", intermittent = true) + @Test fun liveMessage_boltButton_emptyStart() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.open("bob") + click(Ui.Chat.Live) + confirmLiveMessageAlert() + poll(what = "an empty live message: `Cancel live message` in place of the live button and a placeholder item") { + exists(CANCEL) && !exists(Ui.Chat.Live) && chatModel.chatsContext.chatItems.value.any { it.isLiveDummy } + } + click(CANCEL) + poll(what = "the live button back and the placeholder item gone") { + exists(Ui.Chat.Live) && !exists(CANCEL) && chatModel.chatsContext.chatItems.value.none { it.isLiveDummy } + } + click(Ui.Chat.Live) + poll(what = "a second empty live message") { exists(CANCEL) } + assertFalse("the `Live message!` alert was shown again", seesText("Live message!")) + typeInto(Ui.Chat.Message, "hi!") + bob.expectsLiveStarted("hi!") + click(Ui.Chat.Send) + bob.expectsMessage("alice> [LIVE ended] hi!", CONNECT_TIMEOUT_MS) + } + + @Test fun liveMessage_received_showsLiveBadge() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + val aliceId = bob.contactId("alice") + bob sends "/_send @$aliceId live=on text hello" + val id = chat.itemId("hello") + poll(what = "bob's `hello` marked LIVE") { "LIVE" in textsOf(Ui.Chat.Item(id)) && chat.item(id)?.meta?.isLive == true } + bob sends "/last_item_id @alice" + val bobItemId = bob.eventuallyMatching(Regex("\\d+")).value + bob sends "/_update item @$aliceId $bobItemId live=on text hello there" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the same bubble showing `hello there`, still LIVE") { + chat.item(id)?.content?.text == "hello there" && textsOf(Ui.Chat.Item(id)).let { t -> "LIVE" in t && t.any { "hello there" in it } } + } + bob sends "/_update item @$aliceId $bobItemId text hello there" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the bubble without LIVE once bob ends the live message") { + chat.item(id)?.meta?.isLive == false && textsOf(Ui.Chat.Item(id)).let { t -> "LIVE" !in t && t.any { "hello there" in it } } + } + chat.receivedItemInfo("hello there").run { + val h = history() + val newest = h.indexOfFirst { it.startsWith("hello there") } + val current = h.indexOf("(current)") + val first = h.indexOfFirst { it.startsWith("hello") && !it.startsWith("hello there") } + check(newest in 0 until current && current < first && h.count { it == "(current)" } == 1) { "live message history: $h" } + close() + } + } + + @Test fun liveMessage_chatSwitch_sendsToOriginalChat() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + typeInto(Ui.Chat.Message, "hello") + rightClick(Ui.Chat.Send) + click(Ui.Chat.SendLive) + confirmLiveMessageAlert() + bob.expectsLiveStarted("hello") + val id = chat.itemId("hello") + poll(what = "alice's `hello` marked LIVE") { "LIVE" in textsOf(Ui.Chat.Item(id)) } + backToChatList() + val notes = alice.chatList.open("Private notes") + bob.expectsMessage("alice> [LIVE ended] hello", CONNECT_TIMEOUT_MS) + poll(what = "the notes with an empty message field") { exists(Ui.Chat.Message) && editableText(Ui.Chat.Message).isNullOrEmpty() } + assertFalse("the live message was saved in the notes", "hello" in notes.itemTexts()) + backToChatList() + alice.chatList.open("bob") + poll(what = "bob's chat with `hello` no longer live and an empty message field") { + chat.item(id)?.let { it.content.text == "hello" && !it.meta.isLive } == true && "LIVE" !in textsOf(Ui.Chat.Item(id)) && + editableText(Ui.Chat.Message).isNullOrEmpty() + } + chat.send("bye") + bob expectsMessage "alice> bye" + } + + private fun UiScope.confirmLiveMessageAlert() { + poll(what = "the `Live message!` alert with `Send`") { seesText("Live message!") && "Send" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + + private fun CliPeer.expectsLiveStarted(text: String) { + val line = nextLine(CONNECT_TIMEOUT_MS) + val started = Regex("""^(\d{2}:\d{2} )?alice> \[LIVE started] use /show \[on/off/\d+] """ + Regex.escape(text) + "$") + check(started.matches(line)) { "$name: expected alice's live message `$text` to start, got `$line`; next: ${pending()}" } + } + + private companion object { + val CANCEL = described("Cancel live message") + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MemberReportTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MemberReportTest.kt new file mode 100644 index 0000000000..07471d2ee9 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MemberReportTest.kt @@ -0,0 +1,117 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.composeReport +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.memberOfHostedGroup +import chat.simplex.app.ui.harness.menuOffers +import chat.simplex.app.ui.harness.openReportReasons +import chat.simplex.app.ui.harness.receivesReport +import chat.simplex.app.ui.harness.reportSendFinished +import chat.simplex.app.ui.harness.reportSentAlert +import chat.simplex.app.ui.harness.sendReport +import chat.simplex.common.model.GroupFeature +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.ui.Ui +import org.junit.Test + +class MemberReportTest : UiTest() { + @KnownAppIssue("15.5 item 12", "bob: expected ` report content`, got ` report illegal`") + @Test fun report_member_spam_moderatorsReceive() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = memberOfHostedGroup(alice, bob, cath) + cath sends "#team inappropriate joke" + bob.eventuallyMessage("#team cath> inappropriate joke", CONNECT_TIMEOUT_MS) + val joke = chat.itemId("cath's `inappropriate joke`") { it.content.text == "inappropriate joke" } + openReportReasons(joke) + for (reason in listOf("Spam", "Inappropriate content", "Community guidelines violation", "Inappropriate profile", "Another reason")) { + check(exists(Ui.Chat.ReportReason(reason)) && reason in textsOf(Ui.Chat.ReportReason(reason))) { "`Report reason?` does not offer `$reason`" } + } + check("Cancel" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared))) { "`Report reason?` has no `Cancel`" } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "`Report reason?` closed by `Cancel` with no report in compose") { + !seesText("Report reason?") && exists(Ui.Chat.Message) && !seesText("only group moderators will see it.", substring = true) + } + + val spam = "Report spam: only group moderators will see it." + composeReport(joke, "Spam", spam) + check(editableText(Ui.Chat.Message).isNullOrEmpty() && isEnabled(Ui.Chat.Send)) { "a `Spam` report cannot be sent without a comment" } + sendReport(spam) + reportSentAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.receivesReport("team", "alice", "cath", "inappropriate joke", "report spam") + + val violation = "Report violation: only group moderators will see it." + composeReport(joke, "Community guidelines violation", violation) + sendReport(violation) + reportSentAlert() + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + bob.receivesReport("team", "alice", "cath", "inappropriate joke", "report community") + + val profile = "Report member profile: only group moderators will see it." + composeReport(joke, "Inappropriate profile", profile) + sendReport(profile) + bob.receivesReport("team", "alice", "cath", "inappropriate joke", "report profile") + reportSendFinished() + check(!seesText("Report sent to moderators")) { "`Report sent to moderators` shown again after `Don't show again`" } + + val other = "Report other: only group moderators will see it." + composeReport(joke, "Another reason", other) + waitFor(Ui.Chat.Send) + check(!isEnabled(Ui.Chat.Send)) { "an `Another reason` report can be sent without a comment" } + typeInto(Ui.Chat.Message, "fake giveaway") + hideKeyboard() + poll(what = "the send button enabled once the comment is typed") { isEnabled(Ui.Chat.Send) } + sendReport(other) + bob.receivesReport("team", "alice", "cath", "inappropriate joke", "report other: fake giveaway") + + chat.send("after reports") + bob.expectsMessage("#team alice> after reports", CONNECT_TIMEOUT_MS) + cath.expectsMessage("#team alice> after reports", CONNECT_TIMEOUT_MS) + + val content = "Report content: only group moderators will see it." + composeReport(joke, "Inappropriate content", content) + sendReport(content) + bob.receivesReport("team", "alice", "cath", "inappropriate joke", "report content") + } + + @Test fun report_notOffered_forOwnModeratorOrPrefOff() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + backToChatList() + val chat = alice.chatList.open("team") + bob sends "#team from the owner" + val owners = chat.itemId("bob's `from the owner`") { it.content.text == "from the owner" } + chat.send("mine") + bob.eventuallyMessage("#team alice> mine", CONNECT_TIMEOUT_MS) + val mine = chat.itemId("mine") + check(menuOffers(owners, "report")) { "`Report` not offered to a member on the owner's message" } + check(!menuOffers(mine, "report")) { "`Report` offered on alice's own message" } + + bob sends "/set reports #team off" + bob expects "updated group preferences:" + bob expects "Member reports: off" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "member reports off in team's model") { !groupInfoOf("team").groupFeatureEnabled(GroupFeature.Reports) } + check(!menuOffers(owners, "report")) { "`Report` offered with member reports off" } + + bob sends "/set reports #team on" + bob expects "updated group preferences:" + bob expects "Member reports: on" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "member reports on in team's model") { groupInfoOf("team").groupFeatureEnabled(GroupFeature.Reports) } + check(menuOffers(owners, "report")) { "`Report` not offered again with member reports back on" } + + bob sends "/mr team alice moderator" + bob.eventuallyMatching(Regex("#team: you changed the role of alice to moderator( \\(signed\\))?")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice a moderator of team") { groupInfoOf("team").membership.memberRole == GroupMemberRole.Moderator } + check(!menuOffers(owners, "report")) { "`Report` offered to a moderator" } + check(!menuOffers(owners, "moderate")) { "`Moderate` offered to a moderator on the owner's message" } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MessagingTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MessagingTest.kt index b9f459dcd5..7f7a81d097 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MessagingTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MessagingTest.kt @@ -32,17 +32,6 @@ class MessagingTest : UiTest() { bob expects " all good - you?" } - @Test fun edit_menu_updatesPeer() = uiTest { - val alice = app("alice"); val bob = cli("bob") - connect(bob) - val chat = alice.chatList.open("bob") - chat.send("hello") - bob expectsMessage "alice> hello" - chat.menu("hello", "edit") - chat.replaceAndSend("hey 👋") - bob expectsMessage "alice> [edited] hey 👋" - } - @Test fun delete_ownForEveryone_peerSeesMarkedDeleted() = uiTest { val alice = app("alice"); val bob = cli("bob") connect(bob) diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MultiSelectTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MultiSelectTest.kt new file mode 100644 index 0000000000..5bf8ec02ef --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MultiSelectTest.kt @@ -0,0 +1,112 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertFalse +import org.junit.Test + +class MultiSelectTest : UiTest() { + @Test fun select_toggle_updatesCounter() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice one" + bob sends "@alice two" + val chat = alice.chatList.open("bob") + chat.send("mine") + bob expectsMessage "alice> mine" + val one = chat.itemId("one"); val two = chat.itemId("two"); val mine = chat.itemId("mine") + waitFor(Ui.Chat.Bar.part("title")) + assertFalse("selection title before Select", exists(SEL_TITLE)) + assertFalse("selection Delete before Select", exists(SEL_DELETE)) + hideKeyboard() + chat.menu("one", "select") + poll(what = "`Selected 1` instead of bob's chat bar, Delete enabled") { chat.selectionTitle() == "Selected 1" && !exists(Ui.Chat.Bar.part("title")) && isEnabled(SEL_DELETE) } + chat.toggle(two) + poll(what = "Selected 2") { chat.selectionTitle() == "Selected 2" } + chat.toggle(mine) + poll(what = "Selected 3") { chat.selectionTitle() == "Selected 3" } + chat.toggle(one) + poll(what = "Selected 2 after unselecting `one`") { chat.selectionTitle() == "Selected 2" } + chat.toggle(two) + poll(what = "Selected 1") { chat.selectionTitle() == "Selected 1" } + chat.toggle(mine) + poll(what = "`Nothing selected` with Delete disabled") { chat.selectionTitle() == "Nothing selected" && exists(SEL_DELETE) && !isEnabled(SEL_DELETE) } + click(described("Back")) + poll(what = "selection ended with bob's chat bar back and all three messages kept") { + !exists(SEL_TITLE) && !exists(SEL_DELETE) && exists(Ui.Chat.Bar.part("title")) && listOf(one, two, mine).all { chat.item(it) != null && exists(Ui.Chat.Item(it)) } + } + } + + @Test fun selectDelete_forEveryone_peerSeesBoth() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("hello") + bob expectsMessage "alice> hello" + bob sends "@alice hey" + chat.send("hi") + bob expectsMessage "alice> hi" + chat.send("bye") + bob expectsMessage "alice> bye" + val hello = chat.itemId("hello"); val hey = chat.itemId("hey"); val hi = chat.itemId("hi"); val bye = chat.itemId("bye") + hideKeyboard() + chat.select("hello") + chat.toggle(hey) + poll(what = "Selected 2") { chat.selectionTitle() == "Selected 2" } + click(SEL_DELETE) + poll(what = "`Delete 2 messages?` saying they cannot be undone, with Delete for me") { seesText("Delete 2 messages?") && seesText(FULL_MANY) && exists(FOR_ME) } + assertFalse("For everyone offered for a selection with bob's message", exists(FOR_EVERYONE)) + click(FOR_ME) + poll(what = "`hello` and `hey` removed, selection ended, `hi` kept") { + chat.item(hello) == null && chat.item(hey) == null && !exists(Ui.Chat.Item(hello)) && !exists(Ui.Chat.Item(hey)) && !exists(SEL_TITLE) && exists(Ui.Chat.Item(hi)) + } + chat.select("hi") + chat.toggle(bye) + poll(what = "Selected 2") { chat.selectionTitle() == "Selected 2" } + click(SEL_DELETE) + poll(what = "`Delete 2 messages?` saying they will be marked for deletion, with For everyone") { seesText(MARK_MANY) && exists(FOR_EVERYONE) } + click(FOR_EVERYONE) + bob expectsMessage "alice> [marked deleted] hi" + bob expectsMessage "alice> [marked deleted] bye" + poll(what = "alice's two messages collapsed into `2 messages marked deleted`") { "2 messages marked deleted" in textsOf(Ui.Chat.Item(bye)) && !exists(Ui.Chat.Item(hi)) } + } + + @KnownAppIssue("15.5 item 22", "Forward disabled and Delete enabled after bob deleted it") + @Test fun select_itemDeletedByPeer_recheck() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice one" + val chat = alice.chatList.open("bob") + chat.send("mine") + bob expectsMessage "alice> mine" + val one = chat.itemId("one"); val mine = chat.itemId("mine") + val forward = Ui.Chat.Selection.part("forward") + hideKeyboard() + chat.select("one") + chat.toggle(mine) + poll(what = "`Selected 2` with Forward and Delete enabled") { chat.selectionTitle() == "Selected 2" && isEnabled(forward) && isEnabled(SEL_DELETE) } + bob sends "\\@alice one" + bob.eventually("message marked deleted") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `one` marked deleted") { chat.item(one)?.meta?.itemDeleted != null } + poll(what = "`one` still selected, Forward disabled and Delete enabled after bob deleted it") { + chat.selectionTitle() == "Selected 2" && exists(forward) && !isEnabled(forward) && isEnabled(SEL_DELETE) + } + chat.toggle(one) + poll(what = "`Selected 1` with Forward enabled again once the deleted message is unselected") { + chat.selectionTitle() == "Selected 1" && isEnabled(forward) && isEnabled(SEL_DELETE) + } + } + + private companion object { + val FOR_ME = Ui.Alert.Option(Ui.AlertSource.Shared, "forMe") + val FOR_EVERYONE = Ui.Alert.Option(Ui.AlertSource.Shared, "forEveryone") + val SEL_TITLE = Ui.Chat.Selection.part("title") + val SEL_DELETE = Ui.Chat.Selection.part("delete") + const val FULL_MANY = "Messages will be deleted - this cannot be undone!" + const val MARK_MANY = "Messages will be marked for deletion. The recipient(s) will be able to reveal these messages." + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/NotesTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/NotesTest.kt index 998ede7130..53123e16f9 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/NotesTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/NotesTest.kt @@ -1,7 +1,12 @@ package chat.simplex.app.ui.chat +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect import chat.simplex.common.model.CIDirection +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertFalse import org.junit.Test class NotesTest : UiTest() { @@ -13,4 +18,43 @@ class NotesTest : UiTest() { poll(what = "a local note") { notes.item(id)?.chatDir is CIDirection.LocalSnd } restart().chatList.open("Private notes").itemId("keep in mind") } + + @Test fun notes_editDelete_staysLocal() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val notes = alice.chatList.open("Private notes") + notes.send("keep in mind") + notes.send("ahoy!") + notes.send("scratch") + val ahoy = notes.itemId("ahoy!"); val scratch = notes.itemId("scratch") + hideKeyboard() + rightClick(Ui.Chat.Item(ahoy)) + waitFor(Ui.Chat.ItemMenu(ahoy, "edit")) + assertFalse("a note offers Reply", exists(Ui.Chat.ItemMenu(ahoy, "reply"))) + click(Ui.Chat.ItemMenu(ahoy, "edit")) + poll(what = "`ahoy!` in the message field") { editableText(Ui.Chat.Message) == "ahoy!" } + notes.replaceAndSend("Greetings.") + poll(what = "the same note showing `Greetings.` instead of `ahoy!`") { + notes.item(ahoy)?.content?.text == "Greetings." && textsOf(Ui.Chat.Item(ahoy)).let { t -> t.any { "Greetings." in it } && t.none { "ahoy!" in it } } + } + hideKeyboard() + notes.menu("scratch", "delete") + poll(what = "the `Delete message?` alert with `Delete for me` and without `Delete for everyone`") { exists(FOR_ME) && !exists(FOR_EVERYONE) } + click(FOR_ME) + poll(what = "`scratch` deleted") { notes.item(scratch) == null && !exists(Ui.Chat.Item(scratch)) } + val restarted = restart() + val again = restarted.chatList.open("Private notes") + again.itemId("Greetings.") + poll(what = "after a restart `keep in mind` and `Greetings.`, without `ahoy!` and `scratch`") { + again.itemTexts().let { it.containsAll(listOf("keep in mind", "Greetings.")) && "ahoy!" !in it && "scratch" !in it } + } + backToChatList() + restarted.chatList.open("bob").send("sentinel") + bob.expectsMessage("alice> sentinel", CONNECT_TIMEOUT_MS) + } + + private companion object { + val FOR_ME = Ui.Alert.Option(Ui.AlertSource.Shared, "forMe") + val FOR_EVERYONE = Ui.Alert.Option(Ui.AlertSource.Shared, "forEveryone") + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReactionTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReactionTest.kt new file mode 100644 index 0000000000..0c393a3187 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReactionTest.kt @@ -0,0 +1,89 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.PeerFlags +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.closeContactInfo +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.offeredReactions +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.common.model.ChatFeature +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReactionTest : UiTest() { + @KnownAppIssue("15.5 item 17", "waiting for the chip menu listing `bob` and `you`", intermittent = true) + @Test fun reaction_chipClickOthers_adds() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(reactions = true)) + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("hi") + bob expectsMessage "alice> hi" + bob sends "+1 @alice hi" + bob expects "added 👍" + val hi = chat.itemId("hi") + val chip = Ui.Chat.ReactionChip(hi, "👍") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's 👍 chip on `hi` without a count") { exists(chip) && chat.reactionCount("hi", "👍") == null } + assertTrue("the menu of `hi` does not offer 👍 before alice reacts", "👍" in offeredReactions(hi)) + click(chip) + bob.expectsMessage("alice> > hi", CONNECT_TIMEOUT_MS) + bob expects " + 👍" + poll(what = "the 👍 chip counting 2") { chat.reactionCount("hi", "👍") == "2" } + offeredReactions(hi).let { assertTrue("the menu still offers 👍 or no longer offers 👎 after alice reacted: $it", "👍" !in it && "👎" in it) } + rightClick(chip) + poll(what = "the chip menu listing `bob` and `you`") { exists(Ui.Chat.ReactionMember(hi, "bob")) && exists(Ui.Chat.ReactionMember(hi, "you")) } + click(Ui.Chat.ReactionMember(hi, "bob")) + poll(what = "bob's contact info opened from the chip menu") { exists(Ui.Info.Alias) && !exists(Ui.Chat.ReactionMember(hi, "bob")) } + closeContactInfo() + click(chip) + bob.expectsMessage("alice> > hi", CONNECT_TIMEOUT_MS) + bob expects " - 👍" + poll(what = "bob's 👍 chip left without a count") { exists(chip) && chat.reactionCount("hi", "👍") == null } + assertTrue("the menu does not offer 👍 again after alice removed hers", "👍" in offeredReactions(hi)) + } + + @Test fun reaction_chipMenu_group_membersAndOpenInfo() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + val chat = ownGroup(alice, "team", bob, cath) + chat.send("hi") + bob.eventuallyMessage("#team alice> hi", CONNECT_TIMEOUT_MS) + cath.eventuallyMessage("#team alice> hi", CONNECT_TIMEOUT_MS) + bob sends "+1 #team hi" + bob.eventually("added 👍") + cath sends "+1 #team hi" + cath.eventually("added 👍") + val hi = chat.itemId("hi") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the 👍 chip on `hi` counting 2") { chat.reactionCount("hi", "👍") == "2" } + rightClick(Ui.Chat.ReactionChip(hi, "👍")) + poll(what = "the chip menu listing bob and cath but not alice") { + exists(Ui.Chat.ReactionMember(hi, "bob")) && exists(Ui.Chat.ReactionMember(hi, "cath")) && !exists(Ui.Chat.ReactionMember(hi, "alice")) + } + click(Ui.Chat.ReactionMember(hi, "cath")) + poll(what = "member info with `Change role` showing `member`") { exists(Ui.MemberInfo.Role) && "member" in textsOf(Ui.MemberInfo.Role) } + click(Ui.MemberInfo.BlockForAll) + poll(what = "the `Block member for all?` alert naming cath") { seesText("Block member for all?") && seesText("All new messages from cath will be hidden!") } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the alert closed on cath's member info") { !seesText("Block member for all?") && exists(Ui.MemberInfo.BlockForAll) } + } + + @Test fun reaction_disabled_noMenuRow() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hi" + val chat = alice.chatList.open("bob") + val hi = chat.itemId("hi") + assertTrue("the menu of `hi` does not offer 👍 while reactions are allowed", "👍" in offeredReactions(hi)) + val aliceId = bob.contactId("alice") + bob sends """/_set prefs @$aliceId {"reactions": {"allow": "no"}}""" + bob expects "you updated preferences for alice:" + bob.eventuallyMatching(Regex("Message reactions: off .*")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `no` for reactions to reach alice") { + !alice.chatList.chat("bob").chatInfo.featureEnabled(ChatFeature.Reactions) + } + assertEquals("the menu of `hi` still offers reactions", emptyList(), offeredReactions(hi)) + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReceiveFileTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReceiveFileTest.kt new file mode 100644 index 0000000000..b4ad5112f5 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReceiveFileTest.kt @@ -0,0 +1,175 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.TestServers +import chat.simplex.app.ui.harness.PREVIEW_IMAGE_DATA +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.attachFile +import chat.simplex.app.ui.harness.closeItemMenu +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.itemFileOf +import chat.simplex.app.ui.harness.plainBytesOf +import chat.simplex.common.model.CIFileStatus +import chat.simplex.common.model.MsgContent +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.MAX_IMAGE_SIZE_AUTO_RCV +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import java.io.File + +class ReceiveFileTest : UiTest() { + @Test fun sndFile_revoke_peerSeesCancelled() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val source = File(exchangeDir(), REVOKED_NAME).apply { writeText(REVOKED) } + val chat = alice.chatList.open("bob") + waitFor(Ui.Chat.Message) + attachFile(source) + hideKeyboard() + click(Ui.Chat.Send) + bob.expectsMessage("alice> sends file $REVOKED_NAME (${REVOKED.length} bytes / ${REVOKED.length} bytes)", CONNECT_TIMEOUT_MS) + bob expects "use /fr 1 [/ | ] to receive it" + val id = chat.itemId("alice's $REVOKED_NAME") { it.chatDir.sent && it.file?.fileName == REVOKED_NAME } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "$REVOKED_NAME uploaded") { itemFileOf(id)?.fileStatus is CIFileStatus.SndComplete } + rightClick(Ui.Chat.Item(id)) + poll(what = "`Revoke file` in the menu of the uploaded file") { + exists(Ui.Chat.ItemMenu(id, "cancelFile")) && "Revoke file" in textsOf(Ui.Chat.ItemMenu(id, "cancelFile")) + } + click(Ui.Chat.ItemMenu(id, "cancelFile")) + poll(what = "the `Revoke file?` alert with `Revoke`") { + seesText("Revoke file?") && seesText("File will be deleted from servers.") && "Revoke" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + assertEquals("alice cancelled sending file 1 ($REVOKED_NAME)", bob.nextLine(CONNECT_TIMEOUT_MS)) + poll(what = "alice's $REVOKED_NAME cancelled") { itemFileOf(id)?.fileStatus is CIFileStatus.SndCancelled } + rightClick(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "select")) + assertFalse("the menu of the revoked file still offers to cancel it", exists(Ui.Chat.ItemMenu(id, "cancelFile"))) + closeItemMenu(id) + val dir = File(bob.createFile(".fr-probe", "probe")).parent + bob sends "/fr 1 $dir/" + bob expects "file cancelled: $REVOKED_NAME" + } + + @Test fun rcvImage_click_opensFullScreen() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "/img @alice $FIXTURE_JPG" + bob expectsMessage "/f @alice $FIXTURE_JPG" + bob expects "use /fc 1 to cancel sending" + bob.eventually("completed uploading file 1 (test.jpg) for alice", CONNECT_TIMEOUT_MS) + val chat = alice.chatList.open("bob") + val id = chat.itemId("bob's image") { it.content.msgContent is MsgContent.MCImage } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the image received and shown in full") { + itemFileOf(id)?.fileStatus is CIFileStatus.RcvComplete && exists(Ui.Chat.ItemFile(id)) + } + check(!exists(Ui.Chat.FullScreenMedia) && !chatModel.fullscreenGalleryVisible.value) { "the full-screen viewer is open before the image is clicked" } + hideKeyboard() + click(Ui.Chat.ItemFile(id)) + poll(what = "the full-screen viewer showing the image") { exists(Ui.Chat.FullScreenMedia) && "Image" in descriptionsOf(Ui.Chat.FullScreenMedia) } + click(Ui.Chat.FullScreenMedia) + poll(what = "the full-screen viewer closed, back to the chat") { !exists(Ui.Chat.FullScreenMedia) && exists(Ui.Chat.ItemFile(id)) } + } + + @Test fun rcvFile_acceptedBeforeUpload_showsWaitingAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val path = bob.createFile(WAITING_NAME, WAITING) + TestServers.stop("xftp1") + bob sends "/f @alice $path" + bob expects "use /fc 1 to cancel sending" + val chat = alice.chatList.open("bob") + val id = chat.itemId("bob's $WAITING_NAME") { it.file?.fileName == WAITING_NAME } + check(itemFileOf(id)?.fileStatus is CIFileStatus.RcvInvitation) { "$WAITING_NAME before the click: ${itemFileOf(id)?.fileStatus}" } + hideKeyboard() + click(Ui.Chat.ItemFile(id)) + poll(what = "$WAITING_NAME accepted and waiting for bob's upload") { itemFileOf(id)?.fileStatus is CIFileStatus.RcvAccepted } + check(!seesText(WAITING_TITLE)) { "the `$WAITING_TITLE` alert is shown before the accepted file is clicked again" } + click(Ui.Chat.ItemFile(id)) + poll(what = "the `$WAITING_TITLE` alert") { seesText(WAITING_TITLE) && seesText(WAITING_INFO) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the `$WAITING_TITLE` alert to close") { !seesText(WAITING_TITLE) } + check(itemFileOf(id)?.fileStatus is CIFileStatus.RcvAccepted) { "$WAITING_NAME left the waiting state with xftp1 down: ${itemFileOf(id)?.fileStatus}" } + val saved = File(exchangeDir(), "saved-$WAITING_NAME").apply { delete() } + TestServers.start("xftp1") + bob.eventually("completed uploading file 1 ($WAITING_NAME) for alice", CONNECT_TIMEOUT_MS) + saveFileAs(saved) { + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "$WAITING_NAME received once xftp1 is back") { itemFileOf(id)?.fileStatus is CIFileStatus.RcvComplete } + } + assertEquals(WAITING, plainBytesOf(itemFileOf(id)!!).toString(Charsets.UTF_8)) + poll(what = "the received $WAITING_NAME written to ${saved.name}") { saved.length() == WAITING.length.toLong() } + assertEquals(WAITING, saved.readText()) + } + + @Test fun rcvImage_large_clickDownloads() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val aliceId = bob.contactId("alice") + bob sends """/_send @$aliceId json [{"filePath": "$LARGE_PNG", "msgContent": {"type": "image", "text": "", "image": "$PREVIEW_IMAGE_DATA"}}]""" + bob.eventually("completed uploading file 1 ($LARGE_PNG_NAME) for alice", CONNECT_TIMEOUT_MS) + bob sends "/img @alice $FIXTURE_JPG" + bob.eventually("completed uploading file 2 (test.jpg) for alice", CONNECT_TIMEOUT_MS) + val chat = alice.chatList.open("bob") + val large = chat.itemId("bob's large image") { it.content.msgContent is MsgContent.MCImage && it.file?.fileName == LARGE_PNG_NAME } + val small = chat.itemId("bob's small image") { it.content.msgContent is MsgContent.MCImage && it.file?.fileName == "test.jpg" } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the small image, sent after the large one, received without a click") { itemFileOf(small)?.fileStatus is CIFileStatus.RcvComplete } + val offered = itemFileOf(large)!! + check(offered.fileStatus is CIFileStatus.RcvInvitation && offered.fileSize > MAX_IMAGE_SIZE_AUTO_RCV) { + "$LARGE_PNG_NAME (${offered.fileSize} bytes) was received without a click: ${offered.fileStatus}" + } + poll(what = "the preview of $LARGE_PNG_NAME with `Asked to receive the image`") { + exists(Ui.Chat.ItemFile(large).part("preview")) && "Asked to receive the image" in descriptionsOf(Ui.Chat.Item(large)) + } + hideKeyboard() + click(Ui.Chat.ItemFile(large).part("preview")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "$LARGE_PNG_NAME received and shown in full, without `Asked to receive the image`") { + itemFileOf(large)?.fileStatus is CIFileStatus.RcvComplete && exists(Ui.Chat.ItemFile(large)) && + "Asked to receive the image" !in descriptionsOf(Ui.Chat.Item(large)) + } + val received = plainBytesOf(itemFileOf(large)!!) + assertEquals(offered.fileSize, received.size.toLong()) + check(received.take(PNG_MAGIC.size) == PNG_MAGIC) { "the received $LARGE_PNG_NAME is not the PNG bob sent: ${received.take(PNG_MAGIC.size)}" } + } + + @Test fun rcvFile_senderDeletedItem_cannotReceive() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val path = bob.createFile(DELETED_NAME, DELETED) + val aliceId = bob.contactId("alice") + bob sends """/_send @$aliceId json [{"filePath": "$path", "msgContent": {"type": "text", "text": "$CAPTION"}}]""" + bob.eventually("completed uploading file 1 ($DELETED_NAME) for alice", CONNECT_TIMEOUT_MS) + val chat = alice.chatList.open("bob") + val id = chat.itemId("`$CAPTION` with $DELETED_NAME") { it.content.text == CAPTION && it.file?.fileName == DELETED_NAME } + rightClick(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "select")) + check(exists(Ui.Chat.ItemMenu(id, "download"))) { "the menu of the offered file has no Download" } + closeItemMenu(id) + bob sends "\\@alice $CAPTION" + bob.eventually("message marked deleted") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the item showing `marked deleted` in place of `$CAPTION`") { + chat.item(id)?.meta?.itemDeleted != null && textsOf(Ui.Chat.Item(id)).let { t -> t.any { "marked deleted" in it } && t.none { CAPTION in it } } + } + rightClick(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "reveal")) + assertFalse("the menu of the deleted item offers Download", exists(Ui.Chat.ItemMenu(id, "download"))) + closeItemMenu(id) + } + + private companion object { + const val REVOKED_NAME = "revoke-me.txt" + const val REVOKED = "This file will be revoked by alice" + const val DELETED_NAME = "deleted.txt" + const val DELETED = "This file goes with a deleted message" + const val CAPTION = "hi, sending a file" + const val WAITING_NAME = "waiting.txt" + const val WAITING = "Uploaded after the file server is back" + const val WAITING_TITLE = "Waiting for file" + const val WAITING_INFO = "File will be received when your contact completes uploading it." + const val FIXTURE_JPG = "tests/fixtures/test.jpg" + const val LARGE_PNG = "website/src/img/simplex_mobile.png" + const val LARGE_PNG_NAME = "simplex_mobile.png" + val PNG_MAGIC = listOf(0x89.toByte(), 'P'.code.toByte(), 'N'.code.toByte(), 'G'.code.toByte()) + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReplyTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReplyTest.kt new file mode 100644 index 0000000000..0ffb428dac --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ReplyTest.kt @@ -0,0 +1,110 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import org.junit.Assert.assertFalse +import org.junit.Test + +class ReplyTest : UiTest() { + @Test fun reply_group_peerSeesMemberQuote() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + val chat = ownGroup(alice, "team", bob, cath) + cath.eventually("#team: member bob is connected", CONNECT_TIMEOUT_MS) + bob sends "#team $QUESTION" + chat.menu(QUESTION, "reply") + chat.send(ANSWER) + bob.eventuallyMessage("#team alice!> > bob $QUESTION", CONNECT_TIMEOUT_MS) + bob expects " $ANSWER" + cath.eventuallyMessage("#team alice> > bob $QUESTION", CONNECT_TIMEOUT_MS) + cath expects " $ANSWER" + val answer = chat.itemId(ANSWER) + poll(what = "alice's reply quoting `bob` / `$QUESTION`") { + textsOf(Ui.Chat.Item(answer)).let { t -> "bob" in t && t.any { QUESTION in it } && t.any { ANSWER in it } } + } + chat.menu(ANSWER, "reply") + chat.send(FOLLOW_UP) + bob.eventuallyMessage("#team alice> > alice $ANSWER", CONNECT_TIMEOUT_MS) + bob expects " $FOLLOW_UP" + cath.eventuallyMessage("#team alice> > alice $ANSWER", CONNECT_TIMEOUT_MS) + cath expects " $FOLLOW_UP" + val followUp = chat.itemId(FOLLOW_UP) + poll(what = "alice's follow-up quoting `alice` / `$ANSWER`") { + textsOf(Ui.Chat.Item(followUp)).let { t -> "alice" in t && "bob" !in t && t.any { ANSWER in it } && t.any { FOLLOW_UP in it } } + } + } + + @Test fun reply_cancelContext() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hello" + val chat = alice.chatList.open("bob") + chat.itemId("hello") + waitFor(Ui.Chat.Message) + check(!seesDescription("Context icon")) { "a context bar is shown before Reply" } + chat.menu("hello", "reply") + poll(what = "the reply bar with `Cancel`") { seesDescription("Context icon") && exists(described("Cancel")) } + typeInto(Ui.Chat.Message, "just text") + hideKeyboard() + click(described("Cancel")) + poll(what = "the reply bar gone with `just text` kept in the field") { !seesDescription("Context icon") && editableText(Ui.Chat.Message) == "just text" } + chat.sendComposed() + bob.expectsMessage("alice> just text", CONNECT_TIMEOUT_MS) + } + + @Test fun reply_whileEditing_replacesEdit() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice question" + val chat = alice.chatList.open("bob") + chat.itemId("question") + chat.send("draft answer") + bob expectsMessage "alice> draft answer" + val draft = chat.itemId("draft answer") + chat.menu("draft answer", "edit") + poll(what = "the edit bar with `draft answer` in the field") { editableText(Ui.Chat.Message) == "draft answer" && exists(described("Cancel")) } + hideKeyboard() + chat.menu("question", "reply") + poll(what = "an empty field under the reply bar") { editableText(Ui.Chat.Message).isNullOrEmpty() && seesDescription("Context icon") && exists(described("Cancel")) } + chat.send("final answer") + bob.expectsMessage("alice> > question", CONNECT_TIMEOUT_MS) + bob expects " final answer" + poll(what = "`draft answer` unchanged and not edited") { chat.item(draft)?.let { it.content.text == "draft answer" && !it.meta.itemEdited } == true } + } + + @Test fun quote_click_deleted_showsNoMessageAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("the original") + bob expectsMessage "alice> the original" + bob types ">@alice (the original) the reply" + bob expectsMessage "@alice > the original" + bob expects " the reply" + val original = chat.itemId("the original") + val reply = chat.itemId("the reply") + hideKeyboard() + click(Ui.Chat.Item(reply).part("quote")) + poll(what = "the quote of an existing message handled, `the original` in view") { UiIdle.inFlight().isEmpty() && exists(Ui.Chat.Item(original)) } + settle() + assertFalse("the `No message` alert for the quote of an existing message", seesText("No message")) + chat.menu("the original", "delete") + click(Ui.Alert.Option(Ui.AlertSource.Shared, "forMe")) + poll(what = "`the original` deleted for alice") { chat.item(original) == null && !exists(Ui.Chat.Item(original)) } + click(Ui.Chat.Item(reply).part("quote")) + poll(what = "the `No message` alert for the quote of the deleted message") { seesText("No message") && seesText("This message was deleted or not received yet.") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the `No message` alert closed") { !seesText("No message") } + } + + private companion object { + const val QUESTION = "hello! how are you?" + const val ANSWER = "hello, all good, you?" + const val FOLLOW_UP = "will tell more" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/SendAttachmentTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/SendAttachmentTest.kt new file mode 100644 index 0000000000..78d49b9a0f --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/SendAttachmentTest.kt @@ -0,0 +1,127 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.attachFile +import chat.simplex.app.ui.harness.attachImage +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.jpegFile +import chat.simplex.app.ui.harness.openAttachmentSheet +import chat.simplex.app.ui.harness.receivesCompressedImage +import chat.simplex.app.ui.harness.receivesFile +import chat.simplex.common.model.GroupFeature +import chat.simplex.common.model.GroupFeatureEnabled +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.MAX_FILE_SIZE_XFTP +import org.junit.Test +import java.io.File +import java.io.RandomAccessFile + +class SendAttachmentTest : UiTest() { + @Test fun attachFile_withText_peerReceivesTextAndFile() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val source = File(exchangeDir(), NOTES_NAME).apply { writeText(NOTES) } + alice.chatList.open("bob") + waitFor(Ui.Chat.Message) + check(!seesDescription("Close sheet") && !seesDescription("Cancel file preview")) { "the attachment sheet or a file preview is shown before Attach" } + attachFile(source) + click(described("Cancel file preview")) + poll(what = "the preview of $NOTES_NAME removed") { !seesDescription("Cancel file preview") && !seesText(NOTES_NAME) } + attachFile(source) + typeInto(Ui.Chat.Message, CAPTION) + hideKeyboard() + click(Ui.Chat.Send) + poll(what = "the preview and the text cleared after Send") { !seesDescription("Cancel file preview") && editableText(Ui.Chat.Message).isNullOrEmpty() } + bob.expectsMessage("alice> $CAPTION", CONNECT_TIMEOUT_MS) + bob expectsMessage "alice> sends file $NOTES_NAME (${NOTES.length} bytes / ${NOTES.length} bytes)" + bob expects "use /fr 1 [/ | ] to receive it" + bob.receivesFile(1, NOTES_NAME, "alice") + } + + @Test fun attachFile_tooLarge_showsLargeFileAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val huge = File(exchangeDir(), HUGE_NAME) + RandomAccessFile(huge, "rw").use { it.setLength(MAX_FILE_SIZE_XFTP + 1) } + try { + val chat = alice.chatList.open("bob") + waitFor(Ui.Chat.Message) + check(!seesText("Large file!")) { "the `Large file!` alert is shown before the file is picked" } + openAttachmentSheet() + openFile(huge) { click(Ui.Chat.Attach("file")) } + poll(what = "the `Large file!` alert with OK and no preview of $HUGE_NAME") { + seesText("Large file!") && "OK" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && !seesDescription("Cancel file preview") + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert closed and still no preview of $HUGE_NAME") { + !seesText("Large file!") && !seesDescription("Cancel file preview") && !seesText(HUGE_NAME) + } + chat.send("after") + bob.expectsMessage("alice> after", CONNECT_TIMEOUT_MS) + } finally { + huge.delete() + } + } + + @Test fun attachImage_withQuote_peerSeesQuote() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val photo = jpegFile(PHOTO_NAME) + bob sends "@alice $QUESTION" + val chat = alice.chatList.open("bob") + chat.menu(QUESTION, "reply") + poll(what = "the reply bar and no image preview") { seesDescription("Context icon") && !seesDescription("Cancel image preview") } + attachImage(photo) + typeInto(Ui.Chat.Message, IMAGE_CAPTION) + hideKeyboard() + click(Ui.Chat.Send) + poll(what = "the image preview, the reply bar and the caption cleared after Send") { + !seesDescription("Cancel image preview") && !seesDescription("Context icon") && editableText(Ui.Chat.Message).isNullOrEmpty() + } + bob.expectsMessage("alice> > $QUESTION", CONNECT_TIMEOUT_MS) + bob expects " $IMAGE_CAPTION" + val sent = bob.receivesCompressedImage("alice> ", 1) + bob.receivesFile(1, sent, "alice") + } + + @Test fun attach_groupFilesProhibited_buttonDisabled() = uiTest { + val alice = app("alice") + val chat = emptyGroup(alice, "team") + val attach = described("Attach") + chat.send("draft") + poll(what = "the attach button enabled in team") { exists(attach) && isEnabled(attach) } + hideKeyboard() + chat.menu("draft", "edit") + poll(what = "the edit bar with `draft` in the field and the attach button disabled") { + editableText(Ui.Chat.Message) == "draft" && exists(described("Cancel")) && exists(attach) && !isEnabled(attach) + } + hideKeyboard() + click(described("Cancel")) + poll(what = "the edit bar closed and the attach button enabled again") { + !exists(described("Cancel")) && editableText(Ui.Chat.Message).isNullOrEmpty() && exists(attach) && isEnabled(attach) + } + val info = chat.groupInfo() + val prefs = info.preferences() + prefs.toggle(GroupFeature.Files) + prefs.save() + poll(what = "files and media prohibited in team") { groupInfoOf("team").fullGroupPreferences.files.enable == GroupFeatureEnabled.OFF } + prefs.backClosing() + info.close() + poll(what = "the attach button disabled in team with files prohibited") { exists(attach) && !isEnabled(attach) } + chat.send("text still goes") + } + + private companion object { + const val NOTES_NAME = "attach-notes.txt" + const val NOTES = "Notes attached through the file chooser" + const val CAPTION = "hi, sending a file" + const val HUGE_NAME = "attach-huge.bin" + const val PHOTO_NAME = "attach-photo.jpg" + const val QUESTION = "hi alice" + const val IMAGE_CAPTION = "hey bob" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/SpecialItemTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/SpecialItemTest.kt new file mode 100644 index 0000000000..ab21ee5be0 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/SpecialItemTest.kt @@ -0,0 +1,90 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.eventIds +import chat.simplex.app.ui.harness.itemMenu +import chat.simplex.app.ui.harness.loadedItemId +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.common.model.CIContent +import chat.simplex.common.model.ChatFeature +import chat.simplex.common.model.ChatItem +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class SpecialItemTest : UiTest() { + @Test fun featureItems_merged_iconRowExpandHide() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.open("bob") + val initial = featureItemIds("the five feature items created on connection", INITIAL.size) + poll(what = "the five feature items collapsed into one row of icons without text") { collapsedInto(initial.max(), initial) } + bob sends "/set voice @alice no" + bob expects "you updated preferences for alice:" + bob.eventuallyMatching(Regex("Voice messages: off \\(you allow: no.*")) + val all = featureItemIds("bob's `Voice messages: off` feature item", INITIAL.size + 1) + val voiceOff = all.max() + poll(what = "bob's change joining the collapsed row of feature icons without text") { collapsedInto(voiceOff, all) } + itemMenu(voiceOff, "expand") + poll(what = "every feature item shown with its text after Expand") { + shows(voiceOff, "Voice messages: off") && INITIAL.all { (feature, text) -> shows(initialItem(feature), text) } + } + itemMenu(voiceOff, "collapse") + poll(what = "the feature items collapsed into the icon row again after Hide") { collapsedInto(voiceOff, all) } + } + + @Test fun events_merged_deleteRange() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + val chat = ownGroup(alice, "team", bob, cath) + val (older, newer) = eventIds("the `connected` events of bob and cath in team", 2) { it.memberConnected != null } + chat.send("keep") + bob.eventuallyMessage("#team alice> keep", CONNECT_TIMEOUT_MS) + val keep = chat.itemId("keep") + val e2ee = loadedItemId("the encryption info of team") { it.content is CIContent.SndGroupE2EEInfo } + poll(what = "the two `connected` events merged into one row") { exists(Ui.Chat.Item(newer)) && !exists(Ui.Chat.Item(older)) } + hideKeyboard() + itemMenu(newer, "delete") + poll(what = "the alert to delete the merged events for alice only") { + seesText("Messages will be deleted - this cannot be undone!") && exists(EVENTS_FOR_ME) && !exists(EVENTS_FOR_EVERYONE) + } + click(EVENTS_FOR_ME) + poll(what = "both `connected` events deleted, `keep` and the encryption info kept") { + chat.item(older) == null && chat.item(newer) == null && !exists(Ui.Chat.Item(newer)) && exists(Ui.Chat.Item(keep)) && chat.item(e2ee) != null + } + } + + private fun UiScope.featureItemIds(what: String, count: Int): List { + var ids = emptyList() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = what) { + ids = chatModel.chatsContext.chatItems.value.filter { it.content is CIContent.RcvChatFeature }.map { it.id } + ids.size == count + } + return ids + } + + private fun initialItem(feature: ChatFeature): Long = + chatModel.chatsContext.chatItems.value.first { it.isFeature(feature) }.id + + private fun ChatItem.isFeature(feature: ChatFeature) = (content as? CIContent.RcvChatFeature)?.feature == feature + + private fun UiScope.collapsedInto(row: Long, ids: List): Boolean = + exists(Ui.Chat.Item(row)) && (ids - row).none { exists(Ui.Chat.Item(it)) } && textsOf(Ui.Chat.Item(row)).none { t -> INITIAL.any { (_, text) -> text.substringBefore(":") in t } } + + private fun UiScope.shows(id: Long, text: String): Boolean = exists(Ui.Chat.Item(id)) && textsOf(Ui.Chat.Item(id)).any { text in it } + + private companion object { + val EVENTS_FOR_ME = Ui.Alert.Option(Ui.AlertSource.Shared, "forMe") + val EVENTS_FOR_EVERYONE = Ui.Alert.Option(Ui.AlertSource.Shared, "forEveryone") + val INITIAL = listOf( + ChatFeature.TimedMessages to "Disappearing messages: off", + ChatFeature.FullDelete to "Delete for everyone: off", + ChatFeature.Reactions to "Message reactions: enabled", + ChatFeature.Voice to "Voice messages: enabled", + ChatFeature.Calls to "Audio/video calls: enabled", + ) + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/TimedMessageTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/TimedMessageTest.kt new file mode 100644 index 0000000000..7f1192578d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/TimedMessageTest.kt @@ -0,0 +1,92 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.PeerFlags +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitGoneFromPeer +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.openTimedMenu +import chat.simplex.app.ui.harness.receivedTtlSeconds +import chat.simplex.common.model.ChatFeature +import chat.simplex.common.model.shortTimeText +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TimedMessageTest : UiTest() { + @Test fun timed_send30s_bothSidesDelete() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(markRead = true)) + connect(bob) + val chat = alice.chatList.open("bob") + typeInto(Ui.Chat.Message, "hello!") + assertFalse("the disappearing message menu is open before the send button is right-clicked", exists(Ui.Chat.SendTimed)) + openTimedMenu() + assertTrue("`1 minute` and `5 minutes` in the disappearing message menu", seesText("1 minute") && seesText("5 minutes")) + click(Ui.Chat.SendTimed.option(30)) + bob.expectsMessage("alice> hello!", CONNECT_TIMEOUT_MS) + val id = chat.itemId("hello!") + poll(what = "alice's `hello!` with a 30 s timer and `${shortTimeText(30)}` in its meta") { + chat.item(id)?.meta?.itemTimed?.ttl == 30 && shortTimeText(30) in textsOf(Ui.Chat.Item(id)) + } + val ttl = bob.receivedTtlSeconds("alice", "hello!") + assertTrue("bob's `hello!` is deleted $ttl s after it was sent, expected about 30 s", ttl in 28L..40L) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`hello!` to disappear from alice's chat 30 s after it was sent") { + chat.item(id) == null && !exists(Ui.Chat.Item(id)) + } + awaitGoneFromPeer(bob, "alice", "alice> hello!") + } + + @Test fun timed_menuHidden_whenFeatureOff() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(markRead = true)) + connect(bob) + val chat = alice.chatList.open("bob") + typeInto(Ui.Chat.Message, "allowed") + openTimedMenu() + click(Ui.Chat.SendTimed.option(30)) + bob.expectsMessage("alice> allowed", CONNECT_TIMEOUT_MS) + chat.itemId("allowed") + bob sends "/set disappear @alice no" + bob expects "you updated preferences for alice:" + bob.eventuallyMatching(Regex("Disappearing messages: off \\(you allow: no.*")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's preference change to turn disappearing messages off for alice") { + !alice.chatList.chat("bob").chatInfo.featureEnabled(ChatFeature.TimedMessages) + } + typeInto(Ui.Chat.Message, "not allowed") + rightClick(Ui.Chat.Send) + poll(what = "the send menu with `Send live message` and without `Disappearing message`") { + exists(Ui.Chat.SendLive) && !exists(Ui.Chat.SendTimed) + } + } + + @Test fun timed_meta_showsTimerAndTtl() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(markRead = true)) + connect(bob) + val chat = alice.chatList.open("bob") + bob sends "/set disappear @alice yes $CHAT_TTL" + bob expects "you updated preferences for alice:" + bob.eventuallyMatching(Regex("Disappearing messages: enabled .*")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's $CHAT_TTL s timer to reach alice") { alice.chatList.chat("bob").chatInfo.timedMessagesTTL == CHAT_TTL } + chat.send("chat timer") + bob.expectsMessage("alice> chat timer", CONNECT_TIMEOUT_MS) + typeInto(Ui.Chat.Message, "own timer") + openTimedMenu() + click(Ui.Chat.SendTimed.option(30)) + bob.expectsMessage("alice> own timer", CONNECT_TIMEOUT_MS) + val chatTimer = chat.itemId("chat timer") + val ownTimer = chat.itemId("own timer") + poll(what = "the chat timer item without a TTL in its meta and the own timer item with `${shortTimeText(30)}`") { + chat.item(chatTimer)?.meta?.itemTimed?.ttl == CHAT_TTL && chat.item(ownTimer)?.meta?.itemTimed?.ttl == 30 && + textsOf(Ui.Chat.Item(chatTimer)).none { it == shortTimeText(CHAT_TTL) || it == shortTimeText(30) } && + shortTimeText(30) in textsOf(Ui.Chat.Item(ownTimer)) + } + val chatTtl = bob.receivedTtlSeconds("alice", "chat timer") + assertTrue("bob's `chat timer` is deleted $chatTtl s after it was sent, expected about $CHAT_TTL s", chatTtl in (CHAT_TTL - 2L)..(CHAT_TTL + 10L)) + val ownTtl = bob.receivedTtlSeconds("alice", "own timer") + assertTrue("bob's `own timer` is deleted $ownTtl s after it was sent, expected about 30 s", ownTtl in 28L..40L) + } + + private companion object { + const val CHAT_TTL = 120 + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/UnreadScrollTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/UnreadScrollTest.kt new file mode 100644 index 0000000000..4a13900012 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/UnreadScrollTest.kt @@ -0,0 +1,199 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitRowText +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.contactOf +import chat.simplex.app.ui.harness.loadedItemId +import chat.simplex.app.ui.harness.loadedText +import chat.simplex.app.ui.harness.loadedNumbered +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.replyViaApi +import chat.simplex.app.ui.harness.sendTextsViaApi +import chat.simplex.app.ui.harness.shownText +import chat.simplex.app.ui.harness.switchAwayFromChat +import chat.simplex.common.model.ChatPagination +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +private const val UNREAD = 60 +private const val FAR = 120 +private const val NOTES = "Private notes" +private const val OLDER = 300 +private const val AROUND_AFTER = ChatPagination.PRELOAD_COUNT * 2 +private const val NEWER = 360 +private const val MAX_PAGES = 150 +private const val BANNER = "Your contact" + +class UnreadScrollTest : UiTest() { + @Test fun unread_openChat_scrollsToFirstUnread() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + for (i in 1..UNREAD) bob sends "@alice m$i" + awaitRowText(alice, "bob", "$UNREAD") + val chat = alice.chatList.open("bob") + val first = chat.itemId("m1") + var below = 0 + poll(what = "`m1` read, and the row and the scroll-down button both counting the unread messages below the view") { + below = alice.chatList.chat("bob").chatStats.unreadCount + chat.item(first)?.isRcvNew == false && below in 1 until UNREAD && + exists(Ui.Chat.ScrollToBottom) && "$below" in textsOf(Ui.Chat.ScrollToBottom) && "$below" in alice.chatList.rowTexts("bob") + } + val newest = chatModel.chatsContext.chatItems.value.lastOrNull { it.content.text == "m$UNREAD" } + check(newest == null || (newest.isRcvNew && !exists(Ui.Chat.Item(newest.id)))) { "`m$UNREAD` was shown or read before the chat was scrolled to it" } + click(Ui.Chat.ScrollToBottom) + val last = chat.itemId("`m$UNREAD` in view after the scroll-down button") { it.content.text == "m$UNREAD" } + var above = 0 + poll(what = "`m$UNREAD` read, no scroll-down button, and the row and the scroll-up button both counting the skipped unread messages") { + above = alice.chatList.chat("bob").chatStats.unreadCount + chat.item(last)?.isRcvNew == false && !exists(Ui.Chat.ScrollToBottom) && above in 1 until below && + exists(Ui.Chat.UnreadAbove) && "$above" in textsOf(Ui.Chat.UnreadAbove) && "$above" in alice.chatList.rowTexts("bob") + } + rightClick(Ui.Chat.UnreadAbove) + click(Ui.Chat.UnreadAbove.part("markRead")) + poll(what = "every message read: no scroll-up button and no unread count on the row") { + alice.chatList.chat("bob").chatStats.unreadCount == 0 && !exists(Ui.Chat.UnreadAbove) && + alice.chatList.rowTexts("bob").none { it.isNotEmpty() && it.all(Char::isDigit) } + } + } + + @Test fun unreadChatFlag_clearedOnOpen() = uiTest { + val alice = app("alice") + alice.chatList.row(NOTES) + val badges = alice.chatList.rowTexts(NOTES).count { it.isEmpty() } + alice.chatList.rowMenu(NOTES, "markUnread") + poll(what = "the unread mark on the notes row") { + alice.chatList.chat(NOTES).chatStats.unreadChat && alice.chatList.rowTexts(NOTES).count { it.isEmpty() } > badges + } + alice.chatList.open(NOTES) + poll(what = "the unread mark cleared by opening the notes") { + !alice.chatList.chat(NOTES).chatStats.unreadChat && alice.chatList.rowTexts(NOTES).count { it.isEmpty() } == badges + } + val restarted = restart() + restarted.chatList.row(NOTES) + check(!restarted.chatList.chat(NOTES).chatStats.unreadChat && restarted.chatList.rowTexts(NOTES).count { it.isEmpty() } == badges) { + "the unread mark is back after a restart" + } + } + + @Test fun pagination_quoteOutsidePage_jumpLoadsOriginal() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("the original") + bob expectsMessage "alice> the original" + val original = chat.itemId("the original") + switchAwayFromChat(alice) + for (i in 1..FAR) bob sends "@alice f$i" + bob types ">@alice (the original) far reply" + bob expectsMessage "@alice > the original" + bob expects " far reply" + awaitRowText(alice, "bob", "far reply") + alice.chatList.rowMenu("bob", "markRead") + poll(what = "bob's chat marked read") { alice.chatList.chat("bob").chatStats.unreadCount == 0 } + alice.chatList.open("bob") + val reply = chat.itemId("far reply") + check(chat.item(original) == null && !exists(Ui.Chat.Item(original))) { "`the original` was loaded with the newest page" } + click(Ui.Chat.Item(reply).part("quote")) + poll(what = "`the original` loaded and in view, with the scroll-down arrow and no count") { + exists(Ui.Chat.Item(original)) && exists(Ui.Chat.ScrollToBottom) && textsOf(Ui.Chat.ScrollToBottom).isEmpty() + } + bob sends "@alice new one" + val newOne = loadedItemId("`new one` received in alice's open chat") { it.content.text == "new one" } + poll(what = "`the original` still in view, `new one` below it and the scroll-down button counting 1") { + exists(Ui.Chat.Item(original)) && !exists(Ui.Chat.Item(newOne)) && exists(Ui.Chat.ScrollToBottom) && "1" in textsOf(Ui.Chat.ScrollToBottom) + } + click(Ui.Chat.ScrollToBottom) + poll(what = "`new one` in view and read, with no scroll-down button") { + exists(Ui.Chat.Item(newOne)) && chat.item(newOne)?.isRcvNew == false && !exists(Ui.Chat.ScrollToBottom) + } + bob sends "@alice latest" + val latest = loadedItemId("`latest` received in alice's open chat") { it.content.text == "latest" } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the chat scrolled down to `latest` by itself") { exists(Ui.Chat.Item(latest)) } + } + + @Test fun pagination_scrollUp_loadsOlder() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's contact ready to send to") { alice.chatList.shown("bob") && contactOf("bob").sndReady } + val texts = (1..OLDER).map { "p$it" } + sendTextsViaApi("bob", texts) + val chat = alice.chatList.open("bob") + chat.itemId("p$OLDER") + val initial = loadedNumbered("p") + check(initial == texts.takeLast(ChatPagination.INITIAL_COUNT)) { "the chat opened with ${initial.size} messages ${initial.firstOrNull()}..${initial.lastOrNull()}, not the newest ${ChatPagination.INITIAL_COUNT}" } + check(!seesText(BANNER)) { "the chat banner is shown before the oldest message is loaded" } + var pages = 0 + while (!seesText(BANNER)) { + if (++pages > MAX_PAGES) throw AssertionError("the chat banner not shown after $MAX_PAGES pages up; oldest loaded: ${loadedNumbered("p").firstOrNull()}") + val loaded = chatModel.chatsContext.chatItems.value.size + if (!scrollChatTowardOlder()) { + poll(what = "older messages loaded at the top of the chat, or the chat banner; oldest loaded: ${loadedNumbered("p").firstOrNull()}") { + chatModel.chatsContext.chatItems.value.size > loaded || seesText(BANNER) + } + } + } + check(loadedNumbered("p") == texts) { "the messages loaded by scrolling up are not `p1`..`p$OLDER` in order: ${loadedNumbered("p").size} loaded" } + } + + @Test fun pagination_afterAround_scrollDownLoadsNewer() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("the original") + bob expectsMessage "alice> the original" + val original = chat.itemId("the original") + switchAwayFromChat(alice) + val texts = (1..NEWER).map { "n$it" } + sendTextsViaApi("bob", texts) + replyViaApi("bob", original, "far reply") + alice.chatList.open("bob") + val reply = chat.itemId("far reply") + check(chat.item(original) == null) { "`the original` was loaded with the newest page" } + click(Ui.Chat.Item(reply).part("quote")) + poll(what = "`the original` loaded around and in view") { exists(Ui.Chat.Item(original)) } + val aroundNewest = "n$AROUND_AFTER" + val firstGap = "n${AROUND_AFTER + 1}" + check(loadedText(aroundNewest) && !loadedText(firstGap) && loadedText("n$NEWER")) { "the jump did not leave a gap after `$aroundNewest`: ${loadedNumbered("n").size} loaded" } + var pages = 0 + while (!loadedText(firstGap)) { + if (shownText(aroundNewest)) throw AssertionError("`$aroundNewest`, the newest message loaded around `the original`, scrolled into view before newer messages were loaded below it") + if (++pages > MAX_PAGES) throw AssertionError("`$firstGap` not loaded after $MAX_PAGES pages down") + if (!scrollChatTowardNewer()) throw AssertionError("the chat stopped scrolling down before `$firstGap` was loaded") + } + check(!shownText(aroundNewest)) { "`$firstGap` was loaded only after `$aroundNewest` scrolled into view" } + while (!exists(Ui.Chat.Item(reply))) { + if (++pages > MAX_PAGES) throw AssertionError("`far reply` not in view after $MAX_PAGES pages down; ${loadedNumbered("n").size} of $NEWER loaded") + if (!scrollChatTowardNewer()) throw AssertionError("the chat stopped scrolling down before `far reply`; ${loadedNumbered("n").size} of $NEWER loaded") + } + check(loadedNumbered("n") == texts) { "the messages loaded by scrolling down are not `n1`..`n$NEWER` in order: ${loadedNumbered("n").size} loaded" } + } + + @Test fun groupRcv_avatarClick_opensMemberInfo() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + val chat = ownGroup(alice, "team", bob, cath) + chat.send("start") + bob.eventuallyMessage("#team alice> start", CONNECT_TIMEOUT_MS) + cath.eventuallyMessage("#team alice> start", CONNECT_TIMEOUT_MS) + bob sends "#team from bob" + val fromBob = chat.itemId("from bob") + cath sends "#team from cath" + val fromCath = chat.itemId("from cath") + poll(what = "the avatars of bob and cath on their messages") { + exists(Ui.Chat.Item(fromBob).part("avatar")) && exists(Ui.Chat.Item(fromCath).part("avatar")) + } + check(!exists(Ui.MemberInfo.BlockForAll)) { "member info is open before an avatar is clicked" } + hideKeyboard() + click(Ui.Chat.Item(fromCath).part("avatar")) + poll(what = "member info with `Change role` and `Block for all`") { exists(Ui.MemberInfo.Role) && exists(Ui.MemberInfo.BlockForAll) } + click(Ui.MemberInfo.BlockForAll) + poll(what = "the `Block member for all?` alert naming cath, not bob") { + seesText("Block member for all?") && seesText("All new messages from cath will be hidden!") && !seesText("All new messages from bob will be hidden!") + } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the alert closed on cath's member info") { !seesText("Block member for all?") && exists(Ui.MemberInfo.BlockForAll) } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/BusinessChatTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/BusinessChatTest.kt new file mode 100644 index 0000000000..9c6d8b3b63 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/BusinessChatTest.kt @@ -0,0 +1,167 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.businessChat +import chat.simplex.app.ui.harness.businessTypeOf +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.connectPeers +import chat.simplex.app.ui.harness.createAddress +import chat.simplex.app.ui.harness.createBusinessAddress +import chat.simplex.app.ui.harness.deleteBusinessChatFromInfo +import chat.simplex.app.ui.harness.enterLinkInNewChatSearch +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.inviteMembers +import chat.simplex.app.ui.harness.leaveBusinessChatFromInfo +import chat.simplex.app.ui.harness.openNewChatFromSearch +import chat.simplex.app.ui.harness.planConfirm +import chat.simplex.app.ui.harness.quiesce +import chat.simplex.app.ui.harness.switchAwayFromChat +import chat.simplex.common.model.BusinessChatType +import chat.simplex.common.model.CIContent +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.model.GroupMemberStatus +import chat.simplex.common.model.RcvGroupEvent +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class BusinessChatTest : UiTest() { + @Test fun business_addTeamMember() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val address = createBusinessAddress(alice) + connect(cath) + bob sends "/c $address" + bob expects "connection request sent!" + bob.eventually("#alice: joining the group...", CONNECT_TIMEOUT_MS) + bob.eventually("#alice: you joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "a customer business chat named bob") { businessTypeOf("bob") == BusinessChatType.Customer } + quiesce() + val chat = alice.chatList.open("bob") + val info = chat.groupInfo() + poll(what = "`Chat preferences` with the chat owners footer in the business chat's info") { + seesText("Chat preferences") && seesText("Only chat owners can change preferences.") && !seesText("Group preferences") + } + check(!exists(Ui.GroupInfo.CreateLink) && !exists(Ui.GroupInfo.GroupLink) && !exists(Ui.GroupInfo.MemberSupport) && + !exists(Ui.GroupInfo.EditProfile) && !exists(Ui.GroupInfo.WelcomeMessage)) { "the business chat's info offers a group link, `Chats with members`, profile or welcome editing" } + scrollTo(Ui.GroupInfo.AddMembers) + check("Add team members" in textsOf(Ui.GroupInfo.AddMembers)) { "the business owner's add button is not `Add team members`: ${textsOf(Ui.GroupInfo.AddMembers)}" } + val invite = inviteMembers() + invite.toggle("cath") + invite.invite() + cath.eventually("#bob: alice invites you to join the group as member", CONNECT_TIMEOUT_MS) + cath expects "use /j bob to accept" + cath sends "/j bob" + cath.eventuallyAll(lines = listOf("#bob: you joined the group", "#bob: member bob_1 is connected"), timeoutMs = CONNECT_TIMEOUT_MS) + bob.eventuallyAll(lines = listOf("#alice: alice_1 added cath to the group (connecting...)", "#alice: new member cath is connected"), timeoutMs = CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "cath listed as connected in the business chat") { info.memberTexts("cath").let { "connected" in it || "complete" in it } } + info.close() + chat.send("welcome from the team") + bob.eventuallyMessage("#alice alice_1> welcome from the team", CONNECT_TIMEOUT_MS) + cath.eventuallyMessage("#bob alice> welcome from the team", CONNECT_TIMEOUT_MS) + bob sends "#alice thanks" + cath.eventuallyMessage("#bob bob_1> thanks", CONNECT_TIMEOUT_MS) + chat.itemId("bob's `thanks` in the business chat") { it.content.text == "thanks" } + chat.groupInfo() + deleteBusinessChatFromInfo("bob") + bob.eventuallyMatching(Regex("#alice: alice_1 deleted the group( \\(signed\\))?"), CONNECT_TIMEOUT_MS) + cath.eventuallyMatching(Regex("#bob: alice deleted the group( \\(signed\\))?"), CONNECT_TIMEOUT_MS) + alice.chatList.gone("bob") + } + + @Test fun customer_connectToBusinessAddress() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = bob.createAddress(business = true) + alice.chatList.row("Private notes") + check(chatModel.chats.value.none { it.chatInfo.chatViewName == "bob" }) { "a chat named bob exists before the address is opened" } + val chat = openNewChatFromSearch(address, "bob") + val bizId = chatModel.chatId.value!! + poll(what = "a prepared business chat with `Tap Connect to chat`, a message field and `Connect`") { + businessTypeOf("bob") == BusinessChatType.Business && seesText("Tap Connect to chat") && + exists(Ui.Chat.Message) && exists(Ui.Chat.Connect) && "Connect" in textsOf(Ui.Chat.Connect) + } + click(Ui.Chat.Connect) + bob.eventually("#alice: accepting business address request...", CONNECT_TIMEOUT_MS) + bob.eventually("#alice: alice_1 joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the banner `Business connection` with `Connect` gone and alice connected") { + seesText("Business connection") && !seesText("Tap Connect to chat") && !exists(Ui.Chat.Connect) && exists(Ui.Chat.Message) && + groupInfoOf("bob").membership.memberStatus == GroupMemberStatus.MemConnected + } + chat.send("hello business") + bob.eventuallyMessage("#alice alice_1> hello business", CONNECT_TIMEOUT_MS) + bob sends "#alice hi alice" + chat.itemId("bob's `hi alice` in the business chat") { it.content.text == "hi alice" } + switchAwayFromChat(alice) + check(chatModel.chatId.value != bizId) { "the business chat is still open before the address is pasted again" } + enterLinkInNewChatSearch(alice, address) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the known business alert for bob with `Open chat` and no member role") { + exists(planConfirm) && "Open chat" in textsOf(planConfirm) && seesText("bob") && !seesText("You are a member") + } + click(planConfirm) + poll(what = "the New chat sheet closed with the business chat open and still one chat named bob") { + !exists(planConfirm) && !exists(Ui.NewChat.Search) && chatModel.chatId.value == bizId && chatModel.chats.value.count { it.chatInfo.chatViewName == "bob" } == 1 + } + } + + @Test fun customer_seesTeamMemberAdded() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + businessChat(bob) + connectPeers(bob, cath) + val chat = alice.chatList.open("bob") + bob sends "/a #alice cath" + bob.eventually("invitation to join the group #alice sent to cath") + cath.eventually("#alice: bob invites you to join the group as member", CONNECT_TIMEOUT_MS) + cath expects "use /j alice to accept" + cath sends "/j alice" + cath.eventuallyAll(lines = listOf("#alice: you joined the group", "#alice: member alice_1 is connected"), timeoutMs = CONNECT_TIMEOUT_MS) + bob.eventually("#alice: cath joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `invited cath` event in the business chat") { + chatModel.chatsContext.chatItems.value.any { ((it.content as? CIContent.RcvGroupEventContent)?.rcvGroupEvent as? RcvGroupEvent.MemberAdded)?.profile?.displayName == "cath" } + } + cath sends "#alice hello from the team" + chat.itemId("cath's `hello from the team` in the business chat") { it.content.text == "hello from the team" } + val info = chat.groupInfo() + poll(what = "`Chat preferences` with the chat owners footer in the customer's chat info") { + seesText("Chat preferences") && seesText("Only chat owners can change preferences.") && !seesText("Group preferences") + } + check(!exists(Ui.GroupInfo.EditProfile) && !exists(Ui.GroupInfo.MemberSupport) && !exists(Ui.GroupInfo.CreateLink)) { "a customer's chat info offers profile editing, `Chats with members` or a group link" } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "cath listed as connected in the business chat") { info.memberTexts("cath").let { "connected" in it || "complete" in it } } + check(info.ownMemberTexts().isNotEmpty() && !exists(Ui.GroupInfo.AddMembers)) { "a customer's member list offers `Add friends`" } + leaveBusinessChatFromInfo("bob") + bob.eventuallyMatching(Regex("#alice: alice_1 left the group( \\(signed\\))?"), CONNECT_TIMEOUT_MS) + cath.eventuallyMatching(Regex("#alice: alice_1 left the group( \\(signed\\))?"), CONNECT_TIMEOUT_MS) + poll(what = "`you left` in the business chat's compose field") { seesText("you left") } + } + + @Test fun business_promoteCustomerToAdmin() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = createBusinessAddress(alice) + bob sends "/c $address" + bob expects "connection request sent!" + bob.eventually("#alice: you joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "a customer business chat named bob") { businessTypeOf("bob") == BusinessChatType.Customer } + quiesce() + val info = alice.chatList.open("bob").groupInfo() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the customer bob listed as connected, without a role label") { + info.memberTexts("bob").let { ("connected" in it || "complete" in it) && "admin" !in it } + } + val member = info.member("bob") + member.pickRole(GroupMemberRole.Admin) + check(seesText("The role will be changed to \"admin\". Everyone in the chat will be notified.")) { + "the `Change role?` alert of a business chat does not use the chat wording" + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.eventuallyMatching(Regex("#alice: \\S+ changed your role from member to admin \\(signed\\)"), CONNECT_TIMEOUT_MS) + poll(what = "bob's role picker at `admin`") { !seesText("Change role?") && "admin" in textsOf(Ui.MemberInfo.Role) } + click(Ui.MemberInfo.Remove) + poll(what = "the `Remove member?` alert saying bob will be removed from the chat") { + seesText("Remove member?") && seesText("Member will be removed from chat - this cannot be undone!") && + exists(Ui.Alert.Option(Ui.AlertSource.Shared, "remove")) + } + click(Ui.Alert.Option(Ui.AlertSource.Shared, "remove")) + bob.eventuallyMatching(Regex("#alice: \\S+ removed you from the group( \\(signed\\))?"), CONNECT_TIMEOUT_MS) + poll(what = "bob no longer a current member of the business chat") { + chatModel.groupMembers.value.none { it.memberProfile.displayName == "bob" && it.memberCurrent } + } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ChannelOwnerBroadcastTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ChannelOwnerBroadcastTest.kt new file mode 100644 index 0000000000..6e50ccf459 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ChannelOwnerBroadcastTest.kt @@ -0,0 +1,26 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.PeerFlags +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.channelLinkViaApi +import chat.simplex.app.ui.harness.createChannel +import chat.simplex.app.ui.harness.joinChannel +import chat.simplex.app.ui.harness.registerChatRelaysViaApi +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ChannelOwnerBroadcastTest : UiTest() { + @Test fun channel_broadcastPlaceholderAndDelivery() = uiTest { + val alice = app("alice"); val relay = cli("relay1", PeerFlags(relay = true)); val bob = cli("bob"); val cath = cli("cath") + registerChatRelaysViaApi(relay) + val chat = createChannel(alice, "team").continueToChannel() + val link = channelLinkViaApi("team") + bob.joinChannel("team", link, relay) + cath.joinChannel("team", link, relay) + poll(what = "the empty message field of team showing the placeholder `Broadcast`") { hintOf(Ui.Chat.Message) == "Broadcast" } + chat.send("hi") + bob.eventuallyMessage("#team> hi [>>]", CONNECT_TIMEOUT_MS) + cath.eventuallyMessage("#team> hi [>>]", CONNECT_TIMEOUT_MS) + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ChannelOwnerCreateTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ChannelOwnerCreateTest.kt new file mode 100644 index 0000000000..5a1f4466b3 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ChannelOwnerCreateTest.kt @@ -0,0 +1,61 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChannelLinkScreen +import chat.simplex.app.ui.harness.PeerFlags +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.channelChatId +import chat.simplex.app.ui.harness.channelLinkViaApi +import chat.simplex.app.ui.harness.createChannel +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.joinChannel +import chat.simplex.app.ui.harness.registerChatRelaysViaApi +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ChannelOwnerCreateTest : UiTest() { + @Test fun createChannel_singleRelayCompletesAndShowsLink() = uiTest { + val alice = app("alice"); val relay = cli("relay1", PeerFlags(relay = true)) + registerChatRelaysViaApi(relay) + alice.newChat() + click(Ui.NewChat.CreateChannel) + typeInto(Ui.NewChannel.Name, "team") + check(channelChatId("team") == null) { "a channel named team exists before `Create public channel` is clicked" } + click(Ui.NewChannel.Create) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Creating channel` step with `0/1 relays active` and no channel link") { + seesText("Creating channel") && seesText("0/1 relays active") && !exists(Ui.GroupLink.Share) + } + relay.eventually("#team: you joined the group as relay", CONNECT_TIMEOUT_MS) + val linkScreen = ChannelLinkScreen(this, "team") + poll(what = "the channel link explanation in place of the `Creating channel` step") { + seesText("You can share a link or a QR code - anybody will be able to join the channel.") && !seesText("Creating channel") + } + groupInfoOf("team").let { check(it.useRelays && it.membership.memberRole == GroupMemberRole.Owner) { "team is not a channel owned by alice: useRelays ${it.useRelays}, role ${it.membership.memberRole}" } } + linkScreen.continueToChannel() + } + + @Test fun channelLink_cliSubscriberJoins() = uiTest { + val alice = app("alice"); val relay = cli("relay1", PeerFlags(relay = true)); val bob = cli("bob") + registerChatRelaysViaApi(relay) + val linkScreen = createChannel(alice, "team") + val link = linkScreen.share() + linkScreen.continueToChannel() + bob.joinChannel("team", link, relay) + } + + @Test fun channelInfo_subscriberCountUpdates() = uiTest { + val alice = app("alice"); val relay = cli("relay1", PeerFlags(relay = true)); val bob = cli("bob"); val cath = cli("cath") + registerChatRelaysViaApi(relay) + val chat = createChannel(alice, "team").continueToChannel() + val link = channelLinkViaApi("team") + chat.groupInfo().apply { + poll(what = "Group info of the new channel with `Subscribers` and `1 subscriber`") { seesText("Subscribers") && seesText("1 subscriber") } + close() + } + bob.joinChannel("team", link, relay) + cath.joinChannel("team", link, relay) + chat.groupInfo() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`3 subscribers` in Group info after bob and cath joined") { seesText("Subscribers") && seesText("3 subscribers") } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ChannelSubscriberTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ChannelSubscriberTest.kt new file mode 100644 index 0000000000..a820ba2d96 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ChannelSubscriberTest.kt @@ -0,0 +1,39 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.PeerFlags +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.createChannel +import chat.simplex.app.ui.harness.joinPreparedChannel +import chat.simplex.app.ui.harness.openPreparedChannelFromSearch +import chat.simplex.common.model.CIDirection +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ChannelSubscriberTest : UiTest() { + @Test fun subscriber_receivesBroadcast() = uiTest { + val alice = app("alice"); val relay = cli("relay1", PeerFlags(relay = true)); val bob = cli("bob") + val link = bob.createChannel("team", relay) + alice.chatList.row("Private notes") + val chat = openPreparedChannelFromSearch(link, "team") + joinPreparedChannel("team", bob, relay) + check(chatModel.chatsContext.chatItems.value.none { it.content.text == "hi" }) { "team shows `hi` before the owner posts it" } + bob sends "#team hi" + chat.itemId("`hi` received from the channel") { it.content.text == "hi" && it.chatDir is CIDirection.ChannelRcv } + } + + @Test fun subscriber_composeDisabled() = uiTest { + val alice = app("alice"); val relay = cli("relay1", PeerFlags(relay = true)); val bob = cli("bob") + val link = bob.createChannel("team", relay) + alice.chatList.row("Private notes") + openPreparedChannelFromSearch(link, "team") + poll(what = "the banner `Tap Join channel` without `you are subscriber`") { seesText("Tap Join channel") && !seesText("you are subscriber") } + joinPreparedChannel("team", bob, relay) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`you are subscriber` in the message field of team") { seesText("you are subscriber") } + click(Ui.Chat.Message) + poll(what = "the `You can't send messages!` alert") { seesText("You can't send messages!") && "OK" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert to close with team still read-only") { !seesText("You can't send messages!") && seesText("you are subscriber") } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/CreateGroupTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/CreateGroupTest.kt new file mode 100644 index 0000000000..6d759d751e --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/CreateGroupTest.kt @@ -0,0 +1,112 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.InviteMembersScreen +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.alertSaying +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.quiesce +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.ModalManager +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class CreateGroupTest : UiTest() { + @Test fun createGroup_buttonDisabledForEmptyOrInvalidName() = uiTest { + val alice = app("alice") + alice.newChat() + click(Ui.NewChat.CreateGroup) + waitFor(Ui.NewGroup.Name) + check(editableText(Ui.NewGroup.Name).isNullOrEmpty() && !exists(Ui.NewGroup.Name.part("invalid"))) { "the new group name is not empty, or is flagged invalid while empty" } + click(Ui.NewGroup.Create) + typeInto(Ui.NewGroup.Name, "a_1") + waitFor(Ui.NewGroup.Name.part("invalid")) + click(Ui.NewGroup.Create) + click(Ui.NewGroup.Name.part("invalid")) + poll(what = "the `Invalid name!` alert offering `a-1`") { seesText("Invalid name!") && seesText("Correct name to a-1?") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the name corrected to `a-1` without the invalid-name icon") { + editableText(Ui.NewGroup.Name) == "a-1" && !exists(Ui.NewGroup.Name.part("invalid")) && !seesText("Invalid name!") + } + check(chatModel.chats.value.none { it.chatInfo is ChatInfo.Group }) { "`Create group` created a group from an empty or invalid name" } + click(Ui.NewGroup.Create) + val id = alice.chatList.chatId("a-1") + poll(what = "group `a-1` open with the invite members sheet") { chatModel.chatId.value == id && seesText("Invite members") } + val groups = chatModel.chats.value.filter { it.chatInfo is ChatInfo.Group }.map { it.chatInfo.chatViewName } + check(groups == listOf("a-1")) { "groups after creating `a-1` are $groups" } + } + + @Test fun createGroup_skipInviting() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.newChat().createGroup("team") + val id = alice.chatList.chatId("team") + val inv = InviteMembersScreen(this) + poll(what = "bob offered, with `Skip inviting members` and no `Invite to group`") { + inv.listed("bob") && exists(Ui.InviteMembers.Skip) && !exists(Ui.InviteMembers.Invite) && seesText("No contacts selected") + } + inv.toggle("bob") + poll(what = "`Invite to group` in place of `Skip inviting members` with bob selected") { + exists(Ui.InviteMembers.Invite) && !exists(Ui.InviteMembers.Skip) && seesText("1 contact(s) selected") + } + inv.toggle("bob") + poll(what = "`Skip inviting members` back after unselecting bob") { + exists(Ui.InviteMembers.Skip) && !exists(Ui.InviteMembers.Invite) && seesText("No contacts selected") + } + click(Ui.InviteMembers.Skip) + poll(what = "the invite sheet closed with team's chat open") { + !exists(Ui.InviteMembers.Role) && !ModalManager.end.hasModalsOpen() && chatModel.chatId.value == id + } + backToChatList() + alice.chatList.open("bob").send("after skip") + bob.expectsMessage("alice> after skip", CONNECT_TIMEOUT_MS) + } + + @Test fun createGroupIncognito_cliJoinerSeesIncognitoHost() = uiTest { + val alice = app("alice"); val bob = cli("bob") + alice.newChat() + click(Ui.NewChat.CreateGroup) + typeInto(Ui.NewGroup.Name, "team") + hideKeyboard() + poll(what = "`Incognito` off with the main profile footer") { exists(Ui.NewGroup.Incognito) && !seesText(RANDOM_PROFILE, substring = true) } + click(Ui.NewGroup.Incognito) + poll(what = "the random profile footer after turning `Incognito` on") { seesText(RANDOM_PROFILE, substring = true) } + click(Ui.NewGroup.Create) + val id = alice.chatList.chatId("team") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "team open with the `Group link` step showing `Share link` and `Continue`, without `Delete link` or the invite sheet") { + chatModel.chatId.value == id && exists(Ui.GroupLink.Share) && exists(Ui.GroupLink.Continue) && !seesText("Delete link") && !exists(Ui.InviteMembers.Role) + } + val membership = groupInfoOf("team").membership + check(membership.memberIncognito) { "team was created with alice's main profile" } + val incognito = membership.memberProfile.displayName + assertNotEquals("alice", incognito) + setClipboardText(null) + click(Ui.GroupLink.Share) + var link: String? = null + poll(what = "team's link shared") { link = clipboardText()?.takeIf { it.isNotBlank() }; link != null } + click(Ui.GroupLink.Continue) + poll(what = "the link step closed with team's chat open") { !exists(Ui.GroupLink.Share) && !ModalManager.end.hasModalsOpen() && chatModel.chatId.value == id } + bob sends "/c ${link!!}" + bob expects "connection request sent!" + bob.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + quiesce() + val chat = ChatScreen(this) + chat.send("hello from the host") + bob.eventuallyMessage("#team $incognito> hello from the host", CONNECT_TIMEOUT_MS) + chat.groupInfo() + scrollTo(Ui.GroupInfo.AddMembers) + check("Invite members" in textsOf(Ui.GroupInfo.AddMembers)) { "the add button of an incognito group is not `Invite members`: ${textsOf(Ui.GroupInfo.AddMembers)}" } + click(Ui.GroupInfo.AddMembers) + alertSaying("Can't invite contacts!", "You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed") + check(!exists(Ui.InviteMembers.Role)) { "the invite sheet opened for an incognito group" } + } + + private companion object { + const val RANDOM_PROFILE = "A new random profile will be shared." + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupEventsTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupEventsTest.kt new file mode 100644 index 0000000000..24a89baed0 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupEventsTest.kt @@ -0,0 +1,114 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.eventIds +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.inviteMembers +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.itemMenu +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.common.model.CIContent +import chat.simplex.common.model.GroupFeature +import chat.simplex.common.model.RcvGroupEvent +import chat.simplex.common.ui.Ui +import org.junit.Test + +private val TWO_CONNECTED = Regex("(bob and cath|cath and bob) connected") + +class GroupEventsTest : UiTest() { + @Test fun groupEvents_memberConnectedMerged() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + connect(cath) + ownGroup(alice, "team", bob, cath) + val (older, newer) = eventIds("the `connected` events of bob and cath in team", 2) { it.memberConnected != null } + fun rowTexts(id: Long) = textsOf(Ui.Chat.Item(id)) + poll(what = "one row `… and … connected` in place of the two events") { + exists(Ui.Chat.Item(newer)) && rowTexts(newer).any { TWO_CONNECTED.containsMatchIn(it) } && !exists(Ui.Chat.Item(older)) + } + itemMenu(newer, "expand") + poll(what = "each `connected` event on a row of its own") { + exists(Ui.Chat.Item(older)) && + listOf(older, newer).all { id -> rowTexts(id).let { t -> t.any { "connected" in it } && t.none { TWO_CONNECTED.containsMatchIn(it) } } } + } + itemMenu(newer, "collapse") + poll(what = "the two events merged into one row again") { + !exists(Ui.Chat.Item(older)) && rowTexts(newer).any { TWO_CONNECTED.containsMatchIn(it) } + } + } + + @Test fun groupEvents_roleChangeItems() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + backToChatList() + val chat = alice.chatList.open("team") + bob sends "/mr team alice admin" + bob.eventuallyMatching(Regex("#team: you changed the role of alice to admin( \\(signed\\))?")) + val role = chat.itemId("bob's `changed your role to admin` in team") { + (it.content as? CIContent.RcvGroupEventContent)?.rcvGroupEvent is RcvGroupEvent.UserRole + } + poll(what = "`changed your role to admin` on its own row") { textsOf(Ui.Chat.Item(role)).any { "changed your role to admin" in it } } + for (welcome in listOf("Hello", "Hello again")) { + bob sends "/set welcome #team $welcome" + bob.eventually("welcome message changed to:") + bob expects welcome + } + val (older, newer) = eventIds("bob's two `updated group profile` events in team", 2) { + (it.content as? CIContent.RcvGroupEventContent)?.rcvGroupEvent is RcvGroupEvent.GroupUpdated + } + poll(what = "`2 group events` in place of the two profile updates, the role change still on its own row") { + textsOf(Ui.Chat.Item(newer)).any { "2 group events" in it } && !exists(Ui.Chat.Item(older)) && + textsOf(Ui.Chat.Item(role)).any { "changed your role to admin" in it } + } + itemMenu(newer, "expand") + poll(what = "both `updated group profile` events on rows of their own") { + listOf(older, newer).all { id -> exists(Ui.Chat.Item(id)) && textsOf(Ui.Chat.Item(id)).let { t -> t.any { "updated group profile" in it } && t.none { "2 group events" in it } } } + } + } + + @Test fun history_newCliMemberReceivesHistory() = uiTest { + val alice = app("alice"); val cath = cli("cath"); val dan = cli("dan") + connect(cath) + connect(dan) + val chat = emptyGroup(alice, "team") + chat.send("hello") + val info = chat.groupInfo() + inviteMembers().apply { + poll(what = "cath offered") { listed("cath") } + toggle("cath") + invite() + } + cath.expectsMessage("#team: alice invites you to join the group as member", CONNECT_TIMEOUT_MS) + cath expects "use /j team to accept" + cath sends "/j team" + cath.eventuallyAll(lines = listOf("#team: you joined the group"), messages = listOf("#team alice> hello [>>]"), timeoutMs = CONNECT_TIMEOUT_MS) + val prefs = info.preferences() + prefs.toggle(GroupFeature.History) + prefs.save() + cath.eventually("alice updated group #team: (signed)", CONNECT_TIMEOUT_MS) + cath expects "updated group preferences:" + cath expects "Recent history: off" + poll(what = "visible history off for team") { !groupInfoOf("team").fullGroupPreferences.history.on } + prefs.backClosing() + inviteMembers().apply { + poll(what = "dan offered") { listed("dan") } + toggle("dan") + invite() + } + dan.expectsMessage("#team: alice invites you to join the group as member", CONNECT_TIMEOUT_MS) + dan expects "use /j team to accept" + dan sends "/j team" + dan.expectsMessage("#team: you joined the group", CONNECT_TIMEOUT_MS) + dan.expectsMessage("#team: member cath is connected", CONNECT_TIMEOUT_MS) + info.close() + chat.send("after") + dan.expectsMessage("#team alice> after", CONNECT_TIMEOUT_MS) + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupInfoTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupInfoTest.kt new file mode 100644 index 0000000000..e5babb2a2a --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupInfoTest.kt @@ -0,0 +1,114 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.groupInfoRow +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.newGroupsViaApi +import chat.simplex.common.model.MsgFilter +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class GroupInfoTest : UiTest() { + @Test fun groupInfo_setLocalAlias() = uiTest { + val alice = app("alice") + var info = emptyGroup(alice, "team").groupInfo() + poll(what = "group info of `team` with an empty `Set chat name…` field") { + exists(Ui.Info.Alias) && editableText(Ui.Info.Alias).isNullOrEmpty() && seesText("Set chat name…") && seesText("team") + } + typeInto(Ui.Info.Alias, "Work") + poll(what = "`Work` saved as team's local name") { groupInfoOf("Work").localAlias == "Work" } + hideKeyboard() + info.close() + backToChatList() + poll(what = "the row reading `Work` instead of `team`") { alice.chatList.rowTexts("Work").let { "Work" in it && "team" !in it } } + val after = restart() + poll(what = "the group listed as `Work` after the restart") { + chatModel.chatRunning.value == true && chatModel.chats.value.any { it.chatInfo.chatViewName == "Work" } + } + poll(what = "the row still reading `Work` after the restart") { after.chatList.rowTexts("Work").let { "Work" in it && "team" !in it } } + info = after.chatList.open("Work").groupInfo() + poll(what = "group info showing the profile name `team` with the local name `Work`") { editableText(Ui.Info.Alias) == "Work" && seesText("team") } + replaceText(Ui.Info.Alias, "") + poll(what = "team's local name cleared") { groupInfoOf("team").localAlias.isEmpty() } + hideKeyboard() + info.close() + backToChatList() + poll(what = "the row reading `team` again") { alice.chatList.rowTexts("team").let { "team" in it && "Work" !in it } } + } + + @Test fun groupInfo_muteCycleWithMentions() = uiTest { + val alice = app("alice") + var info = emptyGroup(alice, "team").groupInfo() + fun mode() = groupInfoOf("team").chatSettings.enableNtfs + poll(what = "`Mute` on the group info button with all notifications on") { exists(Ui.GroupInfo.Mute) && seesText("Mute") && mode() == MsgFilter.All } + click(Ui.GroupInfo.Mute) + poll(what = "mentions-only notifications and `Mute all` on the button") { mode() == MsgFilter.Mentions && seesText("Mute all") && !seesText("Mute") } + click(Ui.GroupInfo.Mute) + poll(what = "notifications off and `Unmute` on the button") { mode() == MsgFilter.None && seesText("Unmute") && !seesText("Mute all") } + info.close() + backToChatList() + poll(what = "the mute icon on team's row") { "Notifications" in alice.chatList.rowDescriptions("team") } + info = alice.chatList.open("team").groupInfo() + poll(what = "`Unmute` on the button after reopening group info") { exists(Ui.GroupInfo.Mute) && seesText("Unmute") } + click(Ui.GroupInfo.Mute) + poll(what = "all notifications and `Mute` on the button") { mode() == MsgFilter.All && seesText("Mute") && !seesText("Unmute") } + info.close() + backToChatList() + poll(what = "no mute icon on team's row") { "Notifications" !in alice.chatList.rowDescriptions("team") } + } + + @Test fun groupInfo_actionButtonsByRole() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + backToChatList() + val info = alice.chatList.open("team").groupInfo() + poll(what = "a member's group info with `Search` and `Mute`") { exists(Ui.GroupInfo.Mute) && exists(Ui.GroupInfo.Preferences) && seesText("Search") && seesText("Mute") } + check(!seesText("Invite")) { "a member's group info offers `Invite`" } + check(listOf(Ui.GroupInfo.CreateLink, Ui.GroupInfo.GroupLink, Ui.GroupInfo.AddMembers, Ui.GroupInfo.MemberSupport, Ui.GroupInfo.Reports).none { exists(it) }) { + "a member's group info shows a link, `Invite members`, `Chats with members` or `Member reports` row" + } + bob sends "/mr team alice admin" + bob.eventuallyMatching(Regex("#team: you changed the role of alice to admin( \\(signed\\))?")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "an admin's group info with `Invite`, `Create group link`, `Invite members`, `Chats with members` and `Member reports`") { + seesText("Invite") && listOf(Ui.GroupInfo.CreateLink, Ui.GroupInfo.AddMembers, Ui.GroupInfo.MemberSupport, Ui.GroupInfo.Reports).all { exists(it) } + } + check(!exists(Ui.GroupInfo.GroupLink)) { "`Group link` shown before a link was created" } + info.createLink() + poll(what = "`Group link` in place of `Create group link`") { exists(Ui.GroupInfo.GroupLink) && !exists(Ui.GroupInfo.CreateLink) } + groupInfoRow(Ui.GroupInfo.GroupLink) + waitFor(Ui.GroupLink.Share, CONNECT_TIMEOUT_MS) + escape() + poll(what = "the group link screen to close to group info") { !exists(Ui.GroupLink.Share) && exists(Ui.GroupInfo.GroupLink) } + } + + @Test fun groupInfo_sendReceiptsOption() = uiTest { + val alice = app("alice") + newGroupsViaApi("club") + var info = emptyGroup(alice, "team").groupInfo() + fun receipts(): List = textsOf(Ui.GroupInfo.SendReceipts) + poll(what = "team's `Send receipts` at `default (…)`") { receipts().any { it.startsWith("default (") } } + click(Ui.GroupInfo.SendReceipts) + click(Ui.GroupInfo.SendReceipts.option(false)) + poll(what = "`no` selected and saved for team") { "no" in receipts() && groupInfoOf("team").chatSettings.sendRcpts == false } + info.close() + val after = restart() + poll(what = "team and club listed after the restart") { + chatModel.chatRunning.value == true && chatModel.chats.value.map { it.chatInfo.chatViewName }.containsAll(listOf("team", "club")) + } + info = after.chatList.open("team").groupInfo() + poll(what = "team's `Send receipts` still `no` after the restart") { "no" in receipts() } + info.close() + backToChatList() + info = after.chatList.open("club").groupInfo() + poll(what = "club's `Send receipts` still at `default (…)`") { receipts().any { it.startsWith("default (") } && groupInfoOf("club").chatSettings.sendRcpts == null } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupLinksTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupLinksTest.kt index aee0ce25ed..2358904581 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupLinksTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupLinksTest.kt @@ -1,14 +1,27 @@ package chat.simplex.app.ui.groups import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.closeGroupLink import chat.simplex.app.ui.harness.connect import chat.simplex.app.ui.harness.connectIncognito +import chat.simplex.app.ui.harness.contactOf +import chat.simplex.app.ui.harness.coreGroupLinkRole +import chat.simplex.app.ui.harness.coreMemberContactId +import chat.simplex.app.ui.harness.described import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.groupInfoOf import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.openNewGroupLink import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.pickGroupLinkRole +import chat.simplex.app.ui.harness.sharedGroupLink +import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.GroupMemberRole import chat.simplex.common.model.GroupMemberStatus +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui import chat.simplex.common.views.helpers.ModalManager import org.junit.Assert.assertNotEquals @@ -88,8 +101,130 @@ class GroupLinksTest : UiTest() { textsOf(Ui.Chat.Item(after)).let { t -> "blocked by admin" in t && t.none { "after block" in it } } && textsOf(Ui.Chat.Item(before)).any { "before block" in it } } + bob sends "#team still blocked" + val again = chat.itemId("still blocked") + poll(what = "bob's two blocked messages collapsed into `2 messages blocked by admin`") { + "2 messages blocked by admin" in textsOf(Ui.Chat.Item(again)) && !exists(Ui.Chat.Item(after)) + } chat.send("after the block") bob.expectsMessage("#team alice> after the block", CONNECT_TIMEOUT_MS) cath.expectsMessage("#team alice> after the block", CONNECT_TIMEOUT_MS) } + + @Test fun groupLink_cliJoinsViaFullLink() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val info = emptyGroup(alice, "team").groupInfo() + poll(what = "`Create group link` in team's info, without a `Group link` row") { exists(Ui.GroupInfo.CreateLink) && !exists(Ui.GroupInfo.GroupLink) } + openNewGroupLink() + poll(what = "the link created by itself, with the QR code, `Initial role`, `Share link` and `Delete link`, and no `Full link` toggle") { + seesDescription("QR Code") && seesText("Initial role") && exists(Ui.GroupLink.Role) && exists(described("Delete link")) && !exists(Ui.Link.ShortLinkToggle) + } + val short = sharedGroupLink() + check(FULL_CONTACT_LINK !in short) { "`Share link` shared the full link `$short` by default" } + enableDeveloperToolsViaApi() + poll(what = "the `Full link` toggle with developer tools on") { exists(Ui.Link.ShortLinkToggle) && "Full link" in textsOf(Ui.Link.ShortLinkToggle) } + click(Ui.Link.ShortLinkToggle) + poll(what = "the toggle reading `Short link`") { "Short link" in textsOf(Ui.Link.ShortLinkToggle) } + val full = sharedGroupLink(previous = short) + check(FULL_CONTACT_LINK in full) { "`Share link` with `Full link` shared `$full`, not the full link" } + bob sends "/c $full" + bob expects "connection request sent!" + bob.eventually("#team: joining the group...", CONNECT_TIMEOUT_MS) + bob.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + closeGroupLink() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob listed as connected in team's members") { "connected" in info.memberTexts("bob") } + } + + @Test fun groupLink_initialRoleObserver() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = emptyGroup(alice, "team") + val info = chat.groupInfo() + val groupId = groupInfoOf("team").groupId + openNewGroupLink() + poll(what = "`Initial role` member") { "member" in textsOf(Ui.GroupLink.Role) && coreGroupLinkRole(groupId) == GroupMemberRole.Member } + pickGroupLinkRole(GroupMemberRole.Observer) + poll(what = "`observer` as the link's initial role in the core") { "observer" in textsOf(Ui.GroupLink.Role) && coreGroupLinkRole(groupId) == GroupMemberRole.Observer } + val link = sharedGroupLink() + bob sends "/c $link" + bob expects "connection request sent!" + bob.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + bob sends "#team hi there" + bob expects "#team: you don't have permission to send messages" + pickGroupLinkRole(GroupMemberRole.Member) + poll(what = "`member` as the link's initial role in the core again") { "member" in textsOf(Ui.GroupLink.Role) && coreGroupLinkRole(groupId) == GroupMemberRole.Member } + cath sends "/c $link" + cath expects "connection request sent!" + cath.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + cath sends "#team hello from cath" + closeGroupLink() + info.close() + chat.itemId("cath's `hello from cath`") { it.content.text == "hello from cath" } + check(chatModel.chatsContext.chatItems.value.none { it.content.text == "hi there" }) { "the observer bob's message reached the group" } + } + + @Test fun groupLink_deleteKeepsMembers() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = emptyGroup(alice, "team") + val info = chat.groupInfo() + openNewGroupLink() + val link = sharedGroupLink() + bob sends "/c $link" + bob expects "connection request sent!" + bob.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + check(!seesText("Delete link?")) { "`Delete link?` is shown before `Delete link` is clicked" } + click(described("Delete link")) + poll(what = "the `Delete link?` alert saying members stay, with `Delete`") { + seesText("Delete link?") && seesText("All group members will remain connected.") && "Delete" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the group link screen offering `Create link` after the deletion") { !exists(Ui.GroupLink.Share) && seesText("Create link") } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the deleted group link to fail its plan on the server") { + cath sends "/_connect plan 1 $link" + cath.eventuallyMatching(GROUP_LINK_PLAN).value.startsWith("error") + } + cath sends "/c $link" + cath.eventuallyMatching(AUTH_FAILED) + closeGroupLink() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob still connected in team's members and cath not listed") { + "connected" in info.memberTexts("bob") && info.memberTexts("cath").isEmpty() + } + info.close() + chat.send("still here") + bob.expectsMessage("#team alice> still here", CONNECT_TIMEOUT_MS) + } + + @Test fun groupLink_existingContactMergedWithMember() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val bobChatId = alice.chatList.chatId("bob") + val chat = emptyGroup(alice, "team") + val link = chat.groupInfo().createLink() + bob sends "/c $link" + bob expects "connection request sent!" + bob.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + bob.eventually("contact and member are merged: alice, #team alice_1", CONNECT_TIMEOUT_MS) + bob expects "use @alice to send messages" + val bobContactId = contactOf("bob").contactId + val groupId = groupInfoOf("team").groupId + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice's core to link bob's member to his existing contact") { coreMemberContactId(groupId, "bob") == bobContactId } + escape() + poll(what = "group info to close") { !ModalManager.end.hasModalsOpen() } + val info = chat.groupInfo() + poll(what = "bob's member in reopened group info linked to his existing contact, with no second chat for bob") { + chatModel.groupMembers.value.firstOrNull { it.memberProfile.displayName == "bob" }?.memberContactId == bobContactId && + chatModel.chats.value.count { (it.chatInfo as? ChatInfo.Direct)?.contact?.profile?.displayName == "bob" } == 1 + } + info.member("bob").message() + poll(what = "bob's existing chat open from his member info") { chatModel.chatId.value == bobChatId && !ModalManager.end.hasModalsOpen() } + ChatScreen(this).send("via member info") + bob.expectsMessage("alice> via member info", CONNECT_TIMEOUT_MS) + } + + private fun enableDeveloperToolsViaApi() = appPrefs.developerTools.set(true) + + private companion object { + const val FULL_CONTACT_LINK = "/contact#/?v=" + val GROUP_LINK_PLAN = Regex("group link: ok to connect.*|error: connection authorization failed.*") + val AUTH_FAILED = Regex("error: connection authorization failed - .*") + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupPreferencesTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupPreferencesTest.kt new file mode 100644 index 0000000000..ab5f4d1635 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupPreferencesTest.kt @@ -0,0 +1,270 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.createOneTimeLink +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.quiesce +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.GroupFeature +import chat.simplex.common.model.GroupFeatureEnabled +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Ignore +import org.junit.Test + +class GroupPreferencesTest : UiTest() { + @Test fun groupPrefs_unsavedChangesAlert() = uiTest { + val alice = app("alice") + val info = emptyGroup(alice, "team").groupInfo() + var prefs = info.preferences() + waitFor(Ui.GroupPrefs.Toggle(GroupFeature.Reports)) + waitFor(Ui.GroupPrefs.Toggle(GroupFeature.Support)) + check(isEnabled(Ui.GroupPrefs.Toggle(GroupFeature.Reactions))) { "the `Message reactions` toggle is disabled" } + check(!isEnabled(Ui.GroupPrefs.Toggle(GroupFeature.Reports)) && !isEnabled(Ui.GroupPrefs.Toggle(GroupFeature.Support))) { "the `Member reports` or `Chat with admins` toggle is enabled" } + poll(what = "reactions allowed before the change") { seesText("Allow message reactions.") } + prefs.toggle(GroupFeature.Reactions) + poll(what = "reactions prohibited after the toggle") { seesText("Prohibit messages reactions.") && !seesText("Allow message reactions.") } + prefs.reset() + poll(what = "reactions allowed again after `Reset`") { seesText("Allow message reactions.") && !seesText("Prohibit messages reactions.") } + prefs.backClosing() + prefs = info.preferences() + poll(what = "reactions allowed when reopened after `Reset`") { seesText("Allow message reactions.") } + prefs.toggle(GroupFeature.Reactions) + poll(what = "reactions prohibited after the toggle") { seesText("Prohibit messages reactions.") && !seesText("Allow message reactions.") } + prefs.backExpectingAlert("Save preferences?") + check("Save and notify group members" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && "Exit without saving" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared))) { "the `Save preferences?` alert has no `Save and notify group members` / `Exit without saving`" } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "group preferences to close after `Exit without saving`") { !exists(Ui.GroupPrefs.MemberAdmission) && !seesText("Save preferences?") } + check(groupInfoOf("team").fullGroupPreferences.reactions.enable == GroupFeatureEnabled.ON) { "`Exit without saving` saved the reactions change" } + prefs = info.preferences() + poll(what = "reactions still allowed after `Exit without saving`") { seesText("Allow message reactions.") } + prefs.toggle(GroupFeature.Reactions) + poll(what = "reactions prohibited after the toggle") { seesText("Prohibit messages reactions.") && !seesText("Allow message reactions.") } + prefs.backExpectingAlert("Save preferences?") + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "group preferences to close with reactions saved off") { + !exists(Ui.GroupPrefs.MemberAdmission) && groupInfoOf("team").fullGroupPreferences.reactions.enable == GroupFeatureEnabled.OFF + } + info.close() + restart().chatList.open("team").groupInfo().preferences() + poll(what = "reactions prohibited after restart") { seesText("Prohibit messages reactions.") && !seesText("Allow message reactions.") } + } + + @Test fun groupPrefs_ownerChangesNotifyAndRestrictMembers() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val link = bob.createOneTimeLink() + bob.eventually("and ask them to connect: /c ") + val file = bob.createFile("p.txt", "x") + val chat = ownGroup(alice, "team", bob) + chat.send("hi") + bob.expectsMessage("#team alice> hi", CONNECT_TIMEOUT_MS) + bob sends "+1 #team hi" + bob expects "added 👍" + val hi = chat.itemId("hi") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's 👍 on `hi`") { exists(Ui.Chat.ReactionChip(hi, "👍")) } + check(menuOffersReaction(hi, "👎")) { "the menu of `hi` offers no 👎 while reactions are allowed" } + var info = chat.groupInfo() + val p = info.preferences() + check(!exists(Ui.GroupPrefs.Ttl) && !exists(Ui.GroupPrefs.Role(GroupFeature.DirectMessages)) && exists(Ui.GroupPrefs.Role(GroupFeature.Voice))) { "`Delete after` / `Enabled for` rows do not match the default preferences" } + p.toggle(GroupFeature.TimedMessages) + poll(what = "`Delete after` shown with `1 day`") { exists(Ui.GroupPrefs.Ttl) && "1 day" in textsOf(Ui.GroupPrefs.Ttl) } + p.ttl(7 * 86400) + p.toggle(GroupFeature.DirectMessages) + poll(what = "`Enabled for` `all members` under `Direct messages`") { + exists(Ui.GroupPrefs.Role(GroupFeature.DirectMessages)) && "all members" in textsOf(Ui.GroupPrefs.Role(GroupFeature.DirectMessages)) + } + p.role(GroupFeature.DirectMessages, GroupMemberRole.Owner) + p.toggle(GroupFeature.FullDelete) + p.toggle(GroupFeature.Reactions) + p.toggle(GroupFeature.Voice) + poll(what = "`Enabled for` hidden under voice messages turned off") { !exists(Ui.GroupPrefs.Role(GroupFeature.Voice)) } + p.role(GroupFeature.Files, GroupMemberRole.Admin) + p.role(GroupFeature.SimplexLinks, GroupMemberRole.Owner) + p.toggle(GroupFeature.History) + p.save() + bob.expectsMessage("alice updated group #team: (signed)", CONNECT_TIMEOUT_MS) + bob expects "updated group preferences:" + bob expects "Disappearing messages: on (1 week)" + bob expects "Direct messages: on for owners" + bob expects "Full deletion: on" + bob expects "Message reactions: off" + bob expects "Voice messages: off" + bob expects "Files and media: on for admins" + bob expects "SimpleX links: on for owners" + bob expects "Recent history: off" + poll(what = "the saved preferences in team's model") { groupInfoOf("team").fullGroupPreferences.history.enable == GroupFeatureEnabled.OFF } + p.backClosing() + info.close() + check(!menuOffersReaction(hi, "👎")) { "the menu of `hi` still offers 👎 with reactions prohibited" } + click(described("Record voice message")) + poll(what = "the `Voice messages prohibited!` alert") { seesText("Voice messages prohibited!") && seesText("Only group owners can enable voice messages.") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob sends "+^ #team hi" + bob expects "bad chat command: feature not allowed Message reactions" + bob sends "#team $link" + bob expects "bad chat command: feature not allowed SimpleX links" + bob sends "/f #team $file" + bob expects "bad chat command: feature not allowed Files and media" + chat.send(link) + bob.expectsMessage("#team alice> $link", CONNECT_TIMEOUT_MS) + hideKeyboard() + info = chat.groupInfo() + val p2 = info.preferences() + p2.toggle(GroupFeature.TimedMessages) + poll(what = "`Delete after` hidden with disappearing messages off") { !exists(Ui.GroupPrefs.Ttl) } + p2.toggle(GroupFeature.Reactions) + p2.save() + bob.expectsMessage("alice updated group #team: (signed)", CONNECT_TIMEOUT_MS) + bob expects "updated group preferences:" + bob expects "Disappearing messages: off" + bob expects "Message reactions: on" + poll(what = "reactions allowed again in team's model") { groupInfoOf("team").fullGroupPreferences.reactions.enable == GroupFeatureEnabled.ON } + p2.backClosing() + info.close() + check(menuOffersReaction(hi, "👎")) { "the menu of `hi` offers no 👎 after reactions were allowed again" } + bob sends "+^ #team hi" + bob expects "added 🚀" + } + + @Test fun groupPrefs_directMessagesForOwnersOnly() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val info = emptyGroup(alice, "team").groupInfo() + val link = info.createLink() + bob sends "/c $link" + bob expects "connection request sent!" + bob.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob listed as connected in team's members") { "connected" in info.memberTexts("bob") } + quiesce() + info.member("bob").message() + poll(what = "the `Can't message group member` alert") { seesText("Can't message group member") && seesText("Direct messages between members are prohibited in this group.") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + goBack() + poll(what = "member info to close to group info") { !exists(Ui.MemberInfo.Message) && exists(Ui.GroupInfo.Preferences) } + bob sends "@alice hi" + bob expects "bad chat command: direct messages not allowed" + val p = info.preferences() + check(!exists(Ui.GroupPrefs.Role(GroupFeature.DirectMessages))) { "`Enabled for` shown under `Direct messages` while they are off" } + p.toggle(GroupFeature.DirectMessages) + poll(what = "`Enabled for` `all members` under `Direct messages`") { + exists(Ui.GroupPrefs.Role(GroupFeature.DirectMessages)) && "all members" in textsOf(Ui.GroupPrefs.Role(GroupFeature.DirectMessages)) + } + p.role(GroupFeature.DirectMessages, GroupMemberRole.Owner) + p.save() + bob.expectsMessage("alice updated group #team: (signed)", CONNECT_TIMEOUT_MS) + bob expects "updated group preferences:" + bob expects "Direct messages: on for owners" + poll(what = "direct messages for owners saved in team's model") { groupInfoOf("team").fullGroupPreferences.directMessages.role == GroupMemberRole.Owner } + p.backClosing() + bob sends "@alice hi" + bob expects "bad chat command: direct messages not allowed" + info.member("bob").message() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "a direct chat with bob opened instead of the alert") { + (chatModel.chats.value.firstOrNull { it.id == chatModel.chatId.value }?.chatInfo as? ChatInfo.Direct)?.contact?.displayName == "bob" && !seesText("Can't message group member") + } + } + + @Test fun groupPrefs_readOnlyForMember() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val link = bob.createOneTimeLink() + bob.eventually("and ask them to connect: /c ") + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + val chat = alice.chatList.open("team") + var info = chat.groupInfo() + var p = info.preferences() + check(!exists(Ui.GroupPrefs.Toggle(GroupFeature.Voice)) && !seesText("Save and notify group members") && !seesText("Reset")) { "a member's group preferences offer toggles, `Reset` or `Save and notify group members`" } + check(seesText("Members can send voice messages.") && seesText("Members can send SimpleX links.") && !seesText("admins")) { "a member's group preferences do not show the host's defaults" } + p.admission() + check(!exists(Ui.MemberAdmission.Review) && seesText("off") && !seesText("all")) { "a member's `Review members` is editable or not `off`" } + bob sends "/set admission review #team all" + bob expects "changed member admission rules" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice's read-only `Review members` to show `all`") { seesText("all") && !seesText("off") } + goBack() + poll(what = "member admission to close to group preferences") { !seesText("Review members before admitting (\"knocking\").") && exists(Ui.GroupPrefs.MemberAdmission) } + p.backClosing() + info.close() + bob sends "#team before voice" + chat.itemId("before voice") + bob sends "/set voice #team on admin" + bob expects "updated group preferences:" + bob expects "Voice messages: on for admins" + val ev = chat.itemId("Voice messages: on (admins)") + check(textsOf(Ui.Chat.Item(ev)).any { "Voice messages: on (admins)" in it }) { "the `Voice messages: on (admins)` item is not shown" } + click(described("Record voice message")) + poll(what = "the `Voice messages prohibited!` alert") { seesText("Voice messages prohibited!") && seesText("Only group owners can enable voice messages.") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + info = chat.groupInfo() + p = info.preferences() + poll(what = "voice messages enabled for admins in alice's read-only preferences") { seesText("Members can send voice messages.") && seesText("Enabled for") && seesText("admins") } + p.backClosing() + info.close() + bob sends "/set links #team on owner" + bob expects "updated group preferences:" + bob expects "SimpleX links: on for owners" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "SimpleX links prohibited for alice in team's model") { !groupInfoOf("team").groupFeatureEnabled(GroupFeature.SimplexLinks) } + typeInto(Ui.Chat.Message, link) + poll(what = "`SimpleX links not allowed` with the send button disabled") { seesText("SimpleX links not allowed") && exists(Ui.Chat.Send) && !isEnabled(Ui.Chat.Send) } + bob sends "/set links #team on" + bob expects "updated group preferences:" + bob expects "SimpleX links: on" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`SimpleX links not allowed` gone with the send button enabled") { !seesText("SimpleX links not allowed") && isEnabled(Ui.Chat.Send) } + pressEnter(Ui.Chat.Message) + poll(what = "the message field to clear after one Enter") { editableText(Ui.Chat.Message).isNullOrEmpty() } + bob.expectsMessage("#team alice> $link", CONNECT_TIMEOUT_MS) + hideKeyboard() + info = chat.groupInfo() + poll(what = "`Chat with admins` shown and `Chats with members` hidden for a member") { seesText("Chat with admins") && !exists(Ui.GroupInfo.MemberSupport) } + info.close() + bob sends "/mr #team alice moderator" + bob.eventuallyMatching(Regex("#team: you changed the role of alice to moderator( \\(signed\\))?")) + info = chat.groupInfo() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Chats with members` shown and `Chat with admins` hidden for a moderator") { exists(Ui.GroupInfo.MemberSupport) && !seesText("Chat with admins") } + info.chatsWithMembers().empty() + } + + @KnownAppIssue("15.5 item 8", "the unsaved `Message reactions` change was discarded when bob's message arrived") + @Test fun groupPrefs_unsavedEditsKeptWhenMessageArrives() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = ownGroup(alice, "team", bob) + val p = chat.groupInfo().preferences() + poll(what = "reactions allowed before the change") { seesText("Allow message reactions.") } + p.toggle(GroupFeature.Reactions) + poll(what = "reactions prohibited after the toggle") { seesText("Prohibit messages reactions.") } + bob sends "#team hello" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `hello` in team's chat") { chatModel.chatsContext.chatItems.value.any { it.content.text == "hello" } } + settle() + check(seesText("Prohibit messages reactions.")) { "the unsaved `Message reactions` change was discarded when bob's message arrived" } + p.save() + bob.expectsMessage("alice updated group #team: (signed)", CONNECT_TIMEOUT_MS) + bob expects "updated group preferences:" + bob expects "Message reactions: off" + } + + @Ignore("commented-out code (\"Sign messages\", GroupPreferences.kt:219-221; plan §11.3)") + @Test fun groupPrefs_signMessages() {} + + private fun UiScope.menuOffersReaction(id: Long, emoji: String): Boolean { + rightClick(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "reply")) + val offered = exists(Ui.Chat.ItemReaction(id, emoji)) + click(Ui.Chat.ItemMenu(id, "select")) + waitFor(Ui.Chat.Selection.part("title")) + escape() + poll(what = "selection mode to end") { !exists(Ui.Chat.Selection.part("title")) } + return offered + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupProfileTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupProfileTest.kt new file mode 100644 index 0000000000..7cef575f59 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupProfileTest.kt @@ -0,0 +1,120 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.editGroupProfile +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.hostGroupWithWelcome +import chat.simplex.app.ui.harness.inviteMembers +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.welcomeMessage +import chat.simplex.app.ui.harness.welcomeRowText +import chat.simplex.common.model.CIDirection +import chat.simplex.common.model.ChatItem +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +private const val WELCOME = "Welcome to the team!" + +class GroupProfileTest : UiTest() { + @Test fun editGroupProfile_renameNotifiesMembers() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + ownGroup(alice, "team", bob).groupInfo() + var p = editGroupProfile() + poll(what = "the profile editor showing `team` with no short description") { p.name() == "team" && p.shortDescr().isNullOrEmpty() } + p.rename("my_team") + p.save() + bob.expectsMessage("alice updated group #team: (signed)", CONNECT_TIMEOUT_MS) + bob expects "changed to #my_team" + poll(what = "team renamed to `my_team` in group info") { + chatModel.chats.value.any { it.chatInfo.chatViewName == "my_team" } && seesText("my_team") + } + p = editGroupProfile() + poll(what = "the profile editor showing `my_team`") { p.name() == "my_team" && p.shortDescr().isNullOrEmpty() } + p.describe("Our team") + p.save() + bob.expectsMessage("alice updated group #my_team: (signed)", CONNECT_TIMEOUT_MS) + bob expects "description changed to: Our team" + poll(what = "`Our team` shown in group info and saved") { seesText("Our team", substring = true) && groupInfoOf("my_team").groupProfile.shortDescr == "Our team" } + } + + @Test fun welcomeMessage_addNotifiesMembers() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + ownGroup(alice, "team", bob).groupInfo() + poll(what = "`Add welcome message` in team's info") { "Add welcome message" in welcomeRowText() } + var w = welcomeMessage() + poll(what = "an empty welcome message editor") { w.text().isEmpty() } + w.type(WELCOME) + w.save() + bob.expectsMessage("alice updated group #team: (signed)", CONNECT_TIMEOUT_MS) + bob expects "welcome message changed to:" + bob expects WELCOME + poll(what = "the welcome message saved for team") { groupInfoOf("team").groupProfile.description == WELCOME } + w.close() + poll(what = "`Welcome message` in team's info after saving") { welcomeRowText().let { "Welcome message" in it && "Add welcome message" !in it } } + w = welcomeMessage() + poll(what = "the editor holding the saved welcome message") { w.text() == WELCOME } + w.replace("") + w.save() + bob.expectsMessage("alice updated group #team: (signed)", CONNECT_TIMEOUT_MS) + bob expects "welcome message removed" + poll(what = "the welcome message removed from team") { groupInfoOf("team").groupProfile.description == null } + w.close() + poll(what = "`Add welcome message` back in team's info") { "Add welcome message" in welcomeRowText() } + } + + @Test fun welcomeMessage_newMemberReceivesItFirst() = uiTest { + val alice = app("alice"); val cath = cli("cath") + connect(cath) + emptyGroup(alice, "team").groupInfo() + val w = welcomeMessage() + w.type(WELCOME) + w.save() + poll(what = "the welcome message saved for team") { groupInfoOf("team").groupProfile.description == WELCOME } + w.close() + val inv = inviteMembers() + poll(what = "cath offered") { inv.listed("cath") } + inv.toggle("cath") + inv.invite() + cath.expectsMessage("#team: alice invites you to join the group as member", CONNECT_TIMEOUT_MS) + cath expects "use /j team to accept" + cath sends "/j team" + cath.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + cath.expectsMessage("#team alice> $WELCOME", CONNECT_TIMEOUT_MS) + } + + @Test fun welcomeMessage_receivedAsFirstItem() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroupWithWelcome(bob, "team", WELCOME, "alice") + joinGroupFromList(alice, bob, "team") + backToChatList() + val chat = alice.chatList.open("team") + val welcome = chat.itemId("bob's welcome message in team") { it.content.text == WELCOME && it.isFrom("bob") } + bob sends "#team after welcome" + val after = chat.itemId("bob's `after welcome` in team") { it.content.text == "after welcome" && it.isFrom("bob") } + val items = chatModel.chatsContext.chatItems.value + val messages = items.filter { it.content.msgContent != null }.map { it.id } + check(messages.firstOrNull() == welcome && messages.indexOf(after) > messages.indexOf(welcome)) { + "bob's welcome message is not the first message in team, before `after welcome`: ${items.map { it.content.text }}" + } + chat.groupInfo() + poll(what = "`Welcome message` and no `Edit group profile` in a member's group info") { + welcomeRowText().let { "Welcome message" in it && "Add welcome message" !in it } && !exists(Ui.GroupInfo.EditProfile) + } + welcomeMessage() + poll(what = "bob's welcome message shown read-only with `Copy`, without the editor or `Save and update group profile`") { + textsOf(Ui.Welcome.Preview).any { WELCOME in it } && seesText("Copy") && !exists(Ui.Welcome.Text) && !exists(Ui.Welcome.Save) + } + } + + private fun ChatItem.isFrom(name: String) = + (chatDir as? CIDirection.GroupRcv)?.groupMember?.memberProfile?.displayName == name +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/InviteMembersCompatTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/InviteMembersCompatTest.kt new file mode 100644 index 0000000000..9ba6d55cdd --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/InviteMembersCompatTest.kt @@ -0,0 +1,32 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.inviteMembers +import chat.simplex.common.model.GroupMemberRole +import org.junit.Test + +class InviteMembersCompatTest : UiTest() { + @Test fun inviteMembers_asObserver() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val info = emptyGroup(alice, "team").groupInfo() + val inv = inviteMembers() + poll(what = "bob offered with `New member role` set to `member`") { inv.listed("bob") && "member" in inv.roleTexts() } + inv.role(GroupMemberRole.Observer) + poll(what = "`New member role` set to `observer`") { "observer" in inv.roleTexts() && "member" !in inv.roleTexts() } + inv.toggle("bob") + inv.invite() + bob.expectsMessage("#team: alice invites you to join the group as observer", CONNECT_TIMEOUT_MS) + bob expects "use /j team to accept" + bob sends "/j team" + bob.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob listed in team's members as a connected observer") { + info.memberTexts("bob").let { "observer" in it && "connected" in it } + } + bob sends "#team hi" + bob expects "#team: you don't have permission to send messages" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/InviteMembersTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/InviteMembersTest.kt new file mode 100644 index 0000000000..23903812a1 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/InviteMembersTest.kt @@ -0,0 +1,107 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.connectIncognito +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.inviteMembers +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.common.model.CIContent +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class InviteMembersTest : UiTest() { + @Test fun inviteMembers_listsOnlyEligibleContacts() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath"); val dan = cli("dan") + connect(bob) + connect(cath) + val incognito = connectIncognito(dan) + val info = ownGroup(alice, "team", bob).groupInfo() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob a connected member of team") { "connected" in info.memberTexts("bob") } + val inv = inviteMembers() + poll(what = "cath and the incognito contact dan offered, the member bob not offered, nothing selected") { + inv.listed("cath") && inv.listed("dan") && !inv.listed("bob") && seesText("No contacts selected") + } + inv.toggle("dan") + poll(what = "the `Can't invite contact!` alert") { + seesText("Can't invite contact!") && + seesText("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile") + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert closed with dan not selected") { !seesText("Can't invite contact!") && seesText("No contacts selected") } + inv.toggle("cath") + poll(what = "cath selected") { seesText("1 contact(s) selected") } + inv.invite() + cath.expectsMessage("#team: alice invites you to join the group as member", CONNECT_TIMEOUT_MS) + cath expects "use /j team to accept" + info.close() + backToChatList() + alice.chatList.open("dan").send("hi dan") + dan.expectsMessage("$incognito> hi dan", CONNECT_TIMEOUT_MS) + } + + @Test fun inviteMembers_reAddInvitedMember() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val info = emptyGroup(alice, "team").groupInfo() + val first = inviteMembers() + poll(what = "bob offered") { first.listed("bob") } + first.toggle("bob") + first.invite() + bob.expectsMessage("#team: alice invites you to join the group as member", CONNECT_TIMEOUT_MS) + bob expects "use /j team to accept" + poll(what = "bob listed as invited in team's members") { "invited" in info.memberTexts("bob") } + val again = inviteMembers() + poll(what = "the invited bob still offered, nothing selected") { again.listed("bob") && seesText("No contacts selected") } + again.toggle("bob") + again.invite() + bob.expectsMessage("#team: alice invites you to join the group as member", CONNECT_TIMEOUT_MS) + bob expects "use /j team to accept" + } + + @Test fun inviteMembers_selectionCounterAndClear() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + connect(cath) + val info = emptyGroup(alice, "team").groupInfo() + val inv = inviteMembers() + poll(what = "bob and cath offered with nothing selected and no `Clear`") { + inv.listed("bob") && inv.listed("cath") && seesText("No contacts selected") && !exists(Ui.InviteMembers.Clear) + } + typeInto(Ui.InviteMembers.Search, "CA") + poll(what = "only cath offered for `CA`") { inv.listed("cath") && !inv.listed("bob") } + hideKeyboard() + inv.toggle("cath") + poll(what = "`1 contact(s) selected` with `Clear`") { seesText("1 contact(s) selected") && exists(Ui.InviteMembers.Clear) } + replaceText(Ui.InviteMembers.Search, "") + poll(what = "bob offered again with cath still selected") { inv.listed("bob") && inv.listed("cath") && seesText("1 contact(s) selected") } + hideKeyboard() + inv.toggle("bob") + poll(what = "`2 contact(s) selected` with `Clear`") { seesText("2 contact(s) selected") && "Clear" in textsOf(Ui.InviteMembers.Clear) } + click(Ui.InviteMembers.Clear) + poll(what = "`No contacts selected` without `Clear` after clearing") { + seesText("No contacts selected") && !seesText("2 contact(s) selected") && !exists(Ui.InviteMembers.Clear) + } + inv.toggle("cath") + poll(what = "only cath selected") { seesText("1 contact(s) selected") } + inv.invite() + cath.expectsMessage("#team: alice invites you to join the group as member", CONNECT_TIMEOUT_MS) + cath expects "use /j team to accept" + info.close() + backToChatList() + val cathChat = alice.chatList.open("cath") + val sent = cathChat.itemId("the sent invitation to team in cath's chat") { + (it.content as? CIContent.SndGroupInvitation)?.groupInvitation?.groupProfile?.displayName == "team" + } + poll(what = "`You sent group invitation` in cath's chat") { textsOf(Ui.Chat.Item(sent)).any { "You sent group invitation" in it } } + backToChatList() + val bobChat = alice.chatList.open("bob") + bobChat.send("not invited") + bob.expectsMessage("alice> not invited", CONNECT_TIMEOUT_MS) + bobChat.itemId("not invited") + check(chatModel.chatsContext.chatItems.value.none { it.content is CIContent.SndGroupInvitation }) { "bob's chat shows a group invitation sent to him" } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/JoiningCompatTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/JoiningCompatTest.kt index d531e34da6..3e7e2e15f3 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/JoiningCompatTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/JoiningCompatTest.kt @@ -3,7 +3,9 @@ package chat.simplex.app.ui.groups import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS import chat.simplex.app.ui.harness.UiTest import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.groupInfoOf import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.common.model.CIContent import chat.simplex.common.model.ChatInfo import chat.simplex.common.model.GroupMemberStatus import chat.simplex.common.ui.Ui @@ -25,4 +27,25 @@ class JoiningCompatTest : UiTest() { bob.eventually("#team: alice joined the group", CONNECT_TIMEOUT_MS) poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "membership of #team to be connected") { membership() == GroupMemberStatus.MemConnected } } + + @Test fun groupInvitation_tapToJoin() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + alice.chatList.row("team") + poll(what = "alice invited to #team") { groupInfoOf("team").membership.memberStatus == GroupMemberStatus.MemInvited } + val chat = alice.chatList.open("bob") + val inv = chat.itemId("bob's invitation to team in his direct chat") { + (it.content as? CIContent.RcvGroupInvitation)?.groupInvitation?.groupProfile?.displayName == "team" + } + poll(what = "`You are invited to group` with `Tap to join` on the invitation") { + textsOf(Ui.Chat.Item(inv)).let { t -> "You are invited to group" in t && t.any { "Tap to join" in it } } + } + click(Ui.Chat.Item(inv).part("join")) + bob.eventually("#team: alice joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice connected to #team and the invitation reading `You joined this group`") { + groupInfoOf("team").membership.memberStatus == GroupMemberStatus.MemConnected && + textsOf(Ui.Chat.Item(inv)).let { t -> t.any { "You joined this group" in it } && t.none { "Tap to join" in it } } + } + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/JoiningTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/JoiningTest.kt new file mode 100644 index 0000000000..bbe9941a2d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/JoiningTest.kt @@ -0,0 +1,265 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.PeerFlags +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.connectionRequestSentToGroup +import chat.simplex.app.ui.harness.createFullGroupLink +import chat.simplex.app.ui.harness.createGroupLink +import chat.simplex.app.ui.harness.createShortAndFullGroupLink +import chat.simplex.app.ui.harness.createWelcomeGroupLink +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.enterLinkInNewChatSearch +import chat.simplex.app.ui.harness.enterLinkInSearch +import chat.simplex.app.ui.harness.groupChatIds +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.openChatById +import chat.simplex.app.ui.harness.openPreparedGroupFromSearch +import chat.simplex.app.ui.harness.pendingConnectionIds +import chat.simplex.app.ui.harness.planConfirm +import chat.simplex.app.ui.harness.planOption +import chat.simplex.app.ui.harness.switchAwayFromChat +import chat.simplex.common.model.CIDirection +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.GroupMemberStatus +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class JoiningTest : UiTest() { + @Test fun joinGroup_pasteShortLinkPrepareAndJoin() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = bob.createWelcomeGroupLink("team", "Welcome!") + bob.eventually("to delete it: /delete link #team (joined members will remain connected to you)") + alice.chatList.row("Private notes") + check(groupChatIds("team").isEmpty()) { "a group named team exists before the link is opened" } + val chat = openPreparedGroupFromSearch(link, "team") + val teamId = chatModel.chatId.value!! + chat.itemId("Welcome!") + poll(what = "the banner `Tap Join group` and a `Join group` button with no message field") { + seesText("Tap Join group") && exists(Ui.Chat.Connect) && "Join group" in textsOf(Ui.Chat.Connect) && !exists(Ui.Chat.Message) + } + click(Ui.Chat.Connect) + bob.eventually("alice: accepting request to join group #team...", CONNECT_TIMEOUT_MS) + bob.eventually("#team: alice joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the message field in place of `Join group` with alice connected") { + exists(Ui.Chat.Message) && !exists(Ui.Chat.Connect) && !seesText("Tap Join group") && + groupInfoOf("team").membership.memberStatus == GroupMemberStatus.MemConnected + } + chat.send("hi team") + bob.eventuallyMessage("#team alice> hi team", CONNECT_TIMEOUT_MS) + switchAwayFromChat(alice) + check(chatModel.chatId.value != teamId) { "team is still the open chat before the link is pasted again" } + enterLinkInNewChatSearch(alice, link) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the known-group alert for team saying `You are a member` with `Open group`") { + exists(planConfirm) && "Open group" in textsOf(planConfirm) && seesText("team") && seesText("You are a member") + } + click(planConfirm) + poll(what = "the New chat sheet closed with team's chat open and still one group named team") { + !exists(planConfirm) && !exists(Ui.NewChat.Search) && chatModel.chatId.value == teamId && groupChatIds("team") == listOf(teamId) + } + } + + @Test fun joinGroup_preparedJoinIncognito() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = bob.createGroupLink("team") + bob.eventually("to delete it: /delete link #team (joined members will remain connected to you)") + val aliceId = chatModel.currentUser.value!!.userId + alice.chatList.row("Private notes") + val chat = openPreparedGroupFromSearch(link, "team") + poll(what = "`Your profile` with alice selected and no `Incognito` option") { + seesText("Your profile") && exists(Ui.Chat.PickerUser(aliceId)) && !exists(Ui.Chat.PickerIncognito) + } + click(Ui.Chat.PickerUser(aliceId)) + poll(what = "the profile list with `Incognito`") { exists(Ui.Chat.PickerIncognito) } + click(Ui.Chat.PickerIncognito) + poll(what = "the list collapsed to `Incognito`") { exists(Ui.Chat.PickerIncognito) && !exists(Ui.Chat.PickerUser(aliceId)) } + click(Ui.Chat.Connect) + val incognito = bob.eventuallyMatching(Regex("(\\S+): accepting request to join group #team\\.\\.\\."), CONNECT_TIMEOUT_MS).groupValues[1] + assertNotEquals("alice", incognito) + bob.eventually("#team: $incognito joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice connected to team with an incognito membership") { + groupInfoOf("team").membership.let { it.memberStatus == GroupMemberStatus.MemConnected && it.memberIncognito } && exists(Ui.Chat.Message) + } + val info = chat.groupInfo() + poll(what = "alice's own member row named `$incognito`, not `alice`") { + info.ownMemberTexts().let { incognito in it && "alice" !in it } + } + } + + @Test fun joinGroup_fullLinkCurrentProfile() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(fullLinks = true)) + val full = bob.createFullGroupLink("team") + alice.chatList.row("Private notes") + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Join group?` alert for the full group link") { + seesText("Join group?") && seesText("You will connect to all group members.", substring = true) && + exists(planOption("useCurrentProfile")) && exists(planOption("useIncognito")) && exists(planOption("cancel")) + } + click(planOption("cancel")) + poll(what = "the alert to close and the search to clear") { + !seesText("Join group?") && editableText(Ui.ChatList.Search).isNullOrEmpty() && isEnabled(Ui.ChatList.Search) + } + check(pendingConnectionIds().isEmpty() && chatModel.chats.value.none { it.chatInfo is ChatInfo.Group }) { "Cancel created a pending connection or a group" } + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Join group?` alert again") { seesText("Join group?") } + click(planOption("useCurrentProfile")) + connectionRequestSentToGroup() + assertEquals("alice: accepting request to join group #team...", bob.nextLine(CONNECT_TIMEOUT_MS)) + bob.eventually("#team: alice joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice connected to team with no pending connection left") { + groupChatIds("team").size == 1 && groupInfoOf("team").membership.memberStatus == GroupMemberStatus.MemConnected && pendingConnectionIds().isEmpty() + } + backToChatList() + alice.chatList.open("team").send("joined via the full link") + bob.eventuallyMessage("#team alice> joined via the full link", CONNECT_TIMEOUT_MS) + } + + @Test fun connectPlan_ownGroupLinkJoinAsSecondMember() = uiTest { + val alice = app("alice") + val info = emptyGroup(alice, "team").groupInfo() + val link = info.createLink() + info.close() + val teamId = alice.chatList.chatId("team") + switchAwayFromChat(alice) + check(chatModel.chatId.value != teamId) { "team is still the open chat before its link is pasted" } + enterLinkInNewChatSearch(alice, link) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Join your group?` alert with `Open group` and `Use current profile`") { + seesText("Join your group?") && seesText("This is your link for group", substring = true) && + exists(planOption("openGroup")) && exists(planOption("useCurrentProfile")) + } + click(planOption("openGroup")) + poll(what = "the alert and the New chat sheet to close with team's chat open") { + !seesText("Join your group?") && !exists(Ui.NewChat.Search) && chatModel.chatId.value == teamId + } + check(groupChatIds("team") == listOf(teamId)) { "`Open group` created another group named team" } + backToChatList() + enterLinkInNewChatSearch(alice, link) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Join your group?` alert again") { seesText("Join your group?") && exists(planOption("useCurrentProfile")) } + click(planOption("useCurrentProfile")) + connectionRequestSentToGroup() + var secondId: String? = null + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "a second group named team, listed, with alice connected as herself") { + secondId = groupChatIds("team").singleOrNull { it != teamId } + val second = chatModel.chats.value.firstOrNull { it.id == secondId }?.chatInfo as? ChatInfo.Group + second != null && second.groupInfo.membership.memberStatus == GroupMemberStatus.MemConnected && !second.groupInfo.membership.memberIncognito && + exists(Ui.ChatList.Row(secondId!!)) + } + backToChatList() + openChatById(teamId).send("to my other self") + backToChatList() + openChatById(secondId!!).itemId("`to my other self` received in the second team") { it.content.text == "to my other self" && it.chatDir is CIDirection.GroupRcv } + } + + @Test fun connectPlan_connectingAllowsReconnect() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(fullLinks = true)) + val full = bob.createFullGroupLink("team") + bob.stop() + alice.chatList.row("Private notes") + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Join group?` alert for the full group link") { seesText("Join group?") && exists(planOption("useCurrentProfile")) } + click(planOption("useCurrentProfile")) + connectionRequestSentToGroup() + poll(what = "one pending connection") { pendingConnectionIds().size == 1 } + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Repeat join request?` with both profile options while bob is offline") { + seesText("Repeat join request?") && seesText("You are already joining the group via this link.", substring = true) && + exists(planOption("useCurrentProfile")) && exists(planOption("useIncognito")) && !seesText("Join group?") + } + click(planOption("cancel")) + poll(what = "the alert to close with still one pending connection") { + !seesText("Repeat join request?") && editableText(Ui.ChatList.Search).isNullOrEmpty() && pendingConnectionIds().size == 1 + } + bob.start() + bob.eventually("alice: accepting request to join group #team...", CONNECT_TIMEOUT_MS) + bob.eventually("#team: alice joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "one group team with alice connected and no pending connection") { + groupChatIds("team").size == 1 && groupInfoOf("team").membership.memberStatus == GroupMemberStatus.MemConnected && pendingConnectionIds().isEmpty() + } + } + + @Test fun groupInvitation_deleteInvitation() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + hostGroup(bob, "club", "alice") + alice.chatList.row("team") + alice.chatList.row("club") + poll(what = "alice invited to team and club") { + listOf("team", "club").all { groupInfoOf(it).membership.memberStatus == GroupMemberStatus.MemInvited } + } + alice.chatList.rowMenu("team", "delete") + poll(what = "the `Delete group?` alert for team saying it is deleted only for alice, with `Delete`") { + seesText("Delete group?") && seesText("team\n\nGroup will be deleted for you - this cannot be undone!") && + "Delete" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + alice.chatList.gone("team") + check(alice.chatList.shown("club") && groupInfoOf("club").membership.memberStatus == GroupMemberStatus.MemInvited) { "deleting team's invitation removed or changed club's" } + alice.chatList.rowMenu("club", "join", "Join") + assertEquals("#club: alice joined the group", bob.nextLine(CONNECT_TIMEOUT_MS)) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice connected to club") { groupInfoOf("club").membership.memberStatus == GroupMemberStatus.MemConnected } + } + + @Test fun joinGroup_fullLinkIncognito() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(fullLinks = true)) + val full = bob.createFullGroupLink("team") + alice.chatList.row("Private notes") + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Join group?` alert with `Use new incognito profile`") { + seesText("Join group?") && exists(planOption("useIncognito")) && exists(planOption("useCurrentProfile")) + } + click(planOption("useIncognito")) + connectionRequestSentToGroup() + val incognito = bob.eventuallyMatching(Regex("(\\S+): accepting request to join group #team\\.\\.\\."), CONNECT_TIMEOUT_MS).groupValues[1] + assertNotEquals("alice", incognito) + bob.eventually("#team: $incognito joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice connected to team with the incognito profile `$incognito`") { + groupChatIds("team").size == 1 && groupInfoOf("team").membership.let { + it.memberStatus == GroupMemberStatus.MemConnected && it.memberIncognito && it.memberProfile.displayName == incognito + } + } + backToChatList() + alice.chatList.open("team").send("hi from incognito") + bob.eventuallyMessage("#team $incognito> hi from incognito", CONNECT_TIMEOUT_MS) + } + + @KnownAppIssue("15.5 item 20", "the `Group already exists!` alert for the group being joined") + @Test fun connectPlan_connectingProhibit() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(fullLinks = true)) + val (short, full) = bob.createShortAndFullGroupLink("team") + bob.stop() + alice.chatList.row("Private notes") + openPreparedGroupFromSearch(short, "team") + val teamId = chatModel.chatId.value!! + poll(what = "`Join group` in the prepared group") { exists(Ui.Chat.Connect) && "Join group" in textsOf(Ui.Chat.Connect) } + click(Ui.Chat.Connect) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the join request sent while bob is offline, with alice not yet connected") { + groupInfoOf("team").let { !it.nextConnectPrepared && it.membership.memberStatus != GroupMemberStatus.MemConnected } + } + switchAwayFromChat(alice) + check(chatModel.chatId.value != teamId) { "team is still the open chat before the full link is pasted" } + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Group already exists!` alert for the group being joined") { + seesText("Group already exists!") && seesText("You are already joining the group", substring = true) && exists(planConfirm) && + !seesText("Join group?") && !seesText("Repeat join request?") + } + click(planConfirm) + poll(what = "the alert closed with still one team and no pending connection") { + !seesText("Group already exists!") && editableText(Ui.ChatList.Search).isNullOrEmpty() && groupChatIds("team") == listOf(teamId) && + pendingConnectionIds().isEmpty() + } + bob.start() + bob.eventually("alice: accepting request to join group #team...", CONNECT_TIMEOUT_MS) + bob.eventually("#team: alice joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice connected to the one team") { + groupChatIds("team") == listOf(teamId) && groupInfoOf("team").membership.memberStatus == GroupMemberStatus.MemConnected + } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/LeaveDeleteClearTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/LeaveDeleteClearTest.kt new file mode 100644 index 0000000000..701e6c3e4b --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/LeaveDeleteClearTest.kt @@ -0,0 +1,110 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.App +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.CliPeer +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.clearChatFromInfo +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.deleteGroupFromInfo +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.leaveGroupFromInfo +import chat.simplex.app.ui.harness.offersOnlyDelete +import chat.simplex.app.ui.harness.offersOnlyLeave +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.ModalManager +import org.junit.Test + +class LeaveDeleteClearTest : UiTest() { + @KnownAppIssue("15.5 item 14", "a list to scroll to groupInfo.delete", intermittent = true) + @Test fun leaveGroup_thenDeleteLocalCopy() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val chat = memberOfBobsTeam(alice, bob) + chat.groupInfo() + check(offersOnlyLeave()) { "a current member's group info does not offer only `Leave group`" } + leaveGroupFromInfo("team") + bob.eventually("#team: alice left the group (signed)", CONNECT_TIMEOUT_MS) + poll(what = "`you left` in team's compose field") { seesText("you left") } + chat.groupInfo() + check(offersOnlyDelete()) { "group info after leaving does not offer only `Delete group`" } + deleteGroupFromInfo("team", "Group will be deleted for you - this cannot be undone!") + alice.chatList.gone("team") + alice.chatList.open("bob").send("after delete") + bob.expectsMessage("alice> after delete", CONNECT_TIMEOUT_MS) + } + + @Test fun deleteGroup_ownerDeletesForAll() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + ownGroup(alice, "team", bob).groupInfo() + deleteGroupFromInfo("team", "Group will be deleted for all members - this cannot be undone!") + bob.expectsMessage("#team: alice deleted the group (signed)", CONNECT_TIMEOUT_MS) + bob expects "use /d #team to delete the local copy of the group" + alice.chatList.gone("team") + } + + @Test fun deleteGroup_memberSeesGroupDeleted() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val chat = memberOfBobsTeam(alice, bob) + bob sends "/d #team" + bob.eventuallyMatching(Regex("#team: you deleted the group( \\(signed\\))?")) + chat.itemId("bob's `deleted group` event in team") { it.content.text == "deleted group" } + poll(what = "`group is deleted` in team's compose field") { seesText("group is deleted") } + chat.groupInfo() + check(offersOnlyDelete()) { "group info of a deleted group does not offer only `Delete group`" } + } + + @Test fun clearChat_removesLocalItemsOnly() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = ownGroup(alice, "team", bob) + chat.send("hello") + bob.expectsMessage("#team alice> hello", CONNECT_TIMEOUT_MS) + bob sends "#team hi there" + val hello = chat.itemId("hello") + val hi = chat.itemId("bob's `hi there` in team") { it.content.text == "hi there" } + hideKeyboard() + chat.groupInfo() + clearChatFromInfo("team") + poll(what = "group info closed with `hello` and `hi there` gone from team's chat") { + !ModalManager.end.hasModalsOpen() && !exists(Ui.Chat.Item(hello)) && !exists(Ui.Chat.Item(hi)) && + chatModel.chatsContext.chatItems.value.none { it.content.text == "hello" || it.content.text == "hi there" } + } + check((alice.chatList.chat("team").chatInfo as ChatInfo.Group).groupInfo.membership.memberCurrent) { "alice is no longer a current member of team after clearing its chat" } + bob sends "#team after clear" + chat.itemId("bob's `after clear` in team") { it.content.text == "after clear" } + chat.send("still here") + bob.expectsMessage("#team alice> still here", CONNECT_TIMEOUT_MS) + } + + @Test fun removedFromGroup_composeDisabled() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val chat = memberOfBobsTeam(alice, bob) + bob sends "/rm team alice" + bob.eventuallyMatching(Regex("#team: you removed alice from the group( \\(signed\\))?")) + chat.itemId("bob's `removed you` event in team") { it.content.text == "removed you" } + poll(what = "`removed from group` in team's compose field") { seesText("removed from group") } + chat.groupInfo() + check(offersOnlyDelete()) { "group info after removal does not offer only `Delete group`" } + } + + private fun UiScope.memberOfBobsTeam(alice: App, bob: CliPeer): ChatScreen { + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + backToChatList() + val chat = alice.chatList.open("team") + poll(what = "team's compose field with no reason it can't send") { + exists(Ui.Chat.Message) && !seesText("you left") && !seesText("group is deleted") && !seesText("removed from group") + } + return chat + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberAdmissionTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberAdmissionTest.kt new file mode 100644 index 0000000000..881b26167b --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberAdmissionTest.kt @@ -0,0 +1,170 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.SupportChatScreen +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.acceptAlert +import chat.simplex.app.ui.harness.createGroupLink +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.joinLink +import chat.simplex.app.ui.harness.pendingLinkJoin +import chat.simplex.app.ui.harness.quiesce +import chat.simplex.common.model.GroupMemberStatus +import chat.simplex.common.model.MemberCriteria +import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.ModalManager +import org.junit.Ignore +import org.junit.Test + +class MemberAdmissionTest : UiTest() { + @Test fun memberAdmission_unsavedChangesAlert() = uiTest { + val alice = app("alice") + val p = emptyGroup(alice, "team").groupInfo().preferences() + var a = p.admission() + poll(what = "`Review members` off") { "off" in a.review() } + a.select(MemberCriteria.All) + poll(what = "`Review members` all after selecting it") { "all" in a.review() } + goBack() + poll(what = "the `Save admission settings?` alert with `Save and notify group members` and `Exit without saving`") { + seesText("Save admission settings?") && exists(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && + "Save and notify group members" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && "Exit without saving" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "member admission to close to group preferences after `Exit without saving`") { + !seesText("Review members before admitting (\"knocking\").") && exists(Ui.GroupPrefs.MemberAdmission) + } + check(groupInfoOf("team").groupProfile.memberAdmission?.review == null) { "`Exit without saving` saved the admission setting" } + a = p.admission() + poll(what = "`Review members` still off after `Exit without saving`") { "off" in a.review() } + a.select(MemberCriteria.All) + goBack() + poll(what = "the `Save admission settings?` alert") { seesText("Save admission settings?") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "member admission to close with review of all members saved") { + groupInfoOf("team").groupProfile.memberAdmission?.review == MemberCriteria.All && !seesText("Save admission settings?") && + !seesText("Review members before admitting (\"knocking\").") && exists(Ui.GroupPrefs.MemberAdmission) + } + a = p.admission() + poll(what = "`Review members` all when reopened") { "all" in a.review() } + } + + @Test fun review_acceptAsMember() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val chat = pendingLinkJoin(alice, bob, "team") + chat.send("before review") + hideKeyboard() + val info = chat.groupInfo() + val cwm = info.chatsWithMembers() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob listed in `Chats with members` as `pending review`") { cwm.rowTexts("bob").let { "bob" in it && "pending review" in it } } + val sc = cwm.open("bob") + poll(what = "bob's join request with `Accept` and `Reject` in his support chat") { + sc.items().any { it.content.text == "New member wants to join the group." } && + exists(Ui.Chat.PendingAccept) && "Accept" in textsOf(Ui.Chat.PendingAccept) && exists(Ui.Chat.PendingReject) && "Reject" in textsOf(Ui.Chat.PendingReject) + } + sc.send("hi") + bob.expectsMessage("#team (support) alice> hi", CONNECT_TIMEOUT_MS) + check("hi" !in chat.itemTexts()) { "alice's support message `hi` was added to team's main chat" } + bob sends "#team (support) hello" + sc.itemId("hello") + sc.acceptVia("acceptAsMember") + val joined = listOf(bob.nextLine(CONNECT_TIMEOUT_MS), bob.nextLine(CONNECT_TIMEOUT_MS)) + check("#team: you joined the group" in joined && joined.any { it.endsWith("#team alice> before review [>>]") }) { "bob's lines after acceptance are not exactly the join and the `before review` history: $joined" } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the support chat to close and bob listed as `member`") { + chatModel.secondaryChatsContext.value == null && cwm.rowTexts("bob").let { "member" in it && "pending review" !in it } + } + val accepted = cwm.open("bob") + accepted.itemId("hello") + check(!exists(Ui.Chat.PendingAccept) && !exists(Ui.Chat.PendingReject)) { "`Accept` / `Reject` still shown for an accepted member" } + accepted.close() + cwm.close() + info.close() + chat.send("after accept") + bob.expectsMessage("#team alice> after accept", CONNECT_TIMEOUT_MS) + } + + @Test fun review_acceptAsObserver() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val chat = pendingLinkJoin(alice, bob, "team") + val info = chat.groupInfo() + val cwm = info.chatsWithMembers() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob listed in `Chats with members` as `pending review`") { "pending review" in cwm.rowTexts("bob") } + cwm.menuAccept("bob") + acceptAlert("acceptAsObserver") + bob.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob listed as `observer`") { cwm.rowTexts("bob").let { "observer" in it && "pending review" !in it } } + bob sends "#team hi" + bob expects "#team: you don't have permission to send messages" + cwm.close() + info.close() + chat.send("for observers") + bob.expectsMessage("#team alice> for observers", CONNECT_TIMEOUT_MS) + } + + @Test fun review_rejectPendingMember() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val chat = pendingLinkJoin(alice, bob, "team") + val cwm = chat.groupInfo().chatsWithMembers() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob listed in `Chats with members` as `pending review`") { "pending review" in cwm.rowTexts("bob") } + val sc = cwm.open("bob") + sc.reject() + poll(what = "the `Reject member?` alert with `Reject` and `Cancel`") { + seesText("Reject member?") && exists(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && + "Reject" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && "Cancel" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the alert to close with bob still pending") { !seesText("Reject member?") && exists(Ui.Chat.PendingReject) } + sc.send("still pending") + bob.expectsMessage("#team (support) alice> still pending", CONNECT_TIMEOUT_MS) + sc.reject() + poll(what = "the `Reject member?` alert") { seesText("Reject member?") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.expectsMessage("#team: alice removed you from the group (signed)", CONNECT_TIMEOUT_MS) + bob expects "use /d #team to delete the group" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the support chat to close after rejecting bob") { chatModel.secondaryChatsContext.value == null } + cwm.empty() + } + + @Test fun joinerPendingReview_acceptedByAdmin() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = bob.createGroupLink("team") + bob.eventually("to delete it: /delete link #team (joined members will remain connected to you)") + bob sends "/set admission review #team all" + bob expects "changed member admission rules" + joinLink(link) + bob.eventuallyAll(lines = listOf("alice: accepting request to join group #team...", "#team: alice connected and pending review"), timeoutMs = CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice pending review in #team") { groupInfoOf("team").membership.memberStatus == GroupMemberStatus.MemPendingReview } + quiesce() + val chat = alice.chatList.open("team") + val sc = SupportChatScreen(this) + sc.waitOpen() + poll(what = "`Chat with admins` opened with the request to wait for review") { + seesText("Chat with admins") && sc.items().any { it.content.text == "Please wait for group moderators to review your request to join the group." } + } + if (appPlatform.isDesktop) poll(what = "`reviewed by admins` in the group's compose field") { seesText("reviewed by admins") } + sc.send("hello admins") + bob.expectsMessage("#team (support: alice) alice> hello admins", CONNECT_TIMEOUT_MS) + bob sends "#team while pending" + bob sends "#team (support: alice) hi alice" + sc.itemId("hi alice") + check(chatModel.chatsContext.chatItems.value.none { it.content.text == "while pending" }) { "bob's group message sent while alice was pending reached her" } + bob sends "/accept member #team alice" + bob expects "#team: alice accepted" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the support chat to close after acceptance") { chatModel.secondaryChatsContext.value == null && !ModalManager.end.hasModalsOpen() } + poll(what = "`reviewed by admins` gone from the group's compose field") { exists(Ui.Chat.Message) && !seesText("reviewed by admins") } + chat.send("hi all") + bob.expectsMessage("#team alice> hi all", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `while pending` delivered once as group history after acceptance") { + chatModel.chatsContext.chatItems.value.count { it.content.text == "while pending" } == 1 + } + } + + @Ignore("host \"pending approval\" needs Haskell `chatHooks` (plan §11.3)") + @Test fun admission_hostPendingApproval() {} + + @Ignore("\"join rejected\" needs Haskell `chatHooks` (plan §11.3)") + @Test fun admission_joinRejectedByHook() {} +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberContactTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberContactTest.kt new file mode 100644 index 0000000000..c1baa1e697 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberContactTest.kt @@ -0,0 +1,78 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.CliPeer +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.confirmAlert +import chat.simplex.app.ui.harness.linkGroupWithDirectMessages +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class MemberContactTest : UiTest() { + @Test fun memberContact_createAndSendInvitation() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val info = linkGroupWithDirectMessages(alice, "team", bob) + bob sends "/set accept member contacts on" + bob expects "ok" + check(directChatWith("bob") == null) { "alice already has a direct chat with bob" } + info.member("bob").message() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "a new direct chat with bob open with `Send direct message to connect`") { + chatModel.chatId.value != null && chatModel.chatId.value == directChatWith("bob")?.id && seesText("Send direct message to connect") + } + val chat = ChatScreen(this) + chat.send("hi") + bob.eventuallyAll(lines = listOf("#team alice is creating direct contact alice with you"), messages = listOf("alice> hi"), timeoutMs = CONNECT_TIMEOUT_MS) + bob.eventually("alice: you can send messages to contact", CONNECT_TIMEOUT_MS) + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(what = "`Send direct message to connect` gone once bob is connected") { !seesText("Send direct message to connect") } + bob sends "@alice hey" + chat.itemId("bob's `hey` in the direct chat") { it.content.text == "hey" } + } + + @Test fun memberContact_acceptAndRejectRequests() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + linkGroupWithDirectMessages(alice, "team", bob, cath).close() + bob.requestsContactWith("alice") + backToChatList() + var chat = alice.chatList.open("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's request with `Accept` and `Reject` in place of the message field") { + exists(Ui.Chat.RequestAccept) && exists(Ui.Chat.RequestReject) && !exists(Ui.Chat.Message) && chat.itemTexts().contains("hi") + } + click(Ui.Chat.RequestAccept) + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the message field in place of `Accept` and `Reject`") { exists(Ui.Chat.Message) && !exists(Ui.Chat.RequestAccept) } + chat.send("welcome") + bob.expectsMessage("alice> welcome", CONNECT_TIMEOUT_MS) + cath.requestsContactWith("alice") + backToChatList() + alice.chatList.open("cath") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "cath's request with `Reject`") { exists(Ui.Chat.RequestReject) } + click(Ui.Chat.RequestReject) + confirmAlert("Reject contact request", "The sender will NOT be notified.", "Reject") + alice.chatList.gone("cath") + backToChatList() + chat = alice.chatList.open("team") + chat.send("end") + cath.expectsMessage("#team alice> end", CONNECT_TIMEOUT_MS) + } + + private fun directChatWith(name: String) = + chatModel.chats.value.firstOrNull { (it.chatInfo as? ChatInfo.Direct)?.contact?.displayName == name } + + private fun CliPeer.requestsContactWith(member: String) { + this sends "@#team $member hi" + eventuallyAll( + lines = listOf( + "member #team $member does not have direct connection, creating", + "contact for member #team $member is created", + "sent invitation to connect directly to member #team $member", + ), + messages = listOf("@$member hi"), + timeoutMs = CONNECT_TIMEOUT_MS, + ) + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberInfoTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberInfoTest.kt index 2c7cf3c7ef..106c4db73f 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberInfoTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberInfoTest.kt @@ -1,11 +1,47 @@ package chat.simplex.app.ui.groups import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.GroupPreferencesScreen +import chat.simplex.app.ui.harness.KnownAppIssue import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.alertSaying +import chat.simplex.app.ui.harness.awaitAppIdle +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.blockForAllViaApi +import chat.simplex.app.ui.harness.closeMemberInfo +import chat.simplex.app.ui.harness.confirmAlert import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.copiedServer +import chat.simplex.app.ui.harness.createGroup +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.groupInfoRow +import chat.simplex.app.ui.harness.groupMemberId +import chat.simplex.app.ui.harness.inviteMembers +import chat.simplex.app.ui.harness.isServer +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.joinsViaLink +import chat.simplex.app.ui.harness.localSmpServer +import chat.simplex.app.ui.harness.memberConnected +import chat.simplex.app.ui.harness.memberConnectionLoaded +import chat.simplex.app.ui.harness.memberOfHostedGroup +import chat.simplex.app.ui.harness.openMemberMenu import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.ownGroupWithTwo +import chat.simplex.app.ui.harness.shownSecurityCode +import chat.simplex.common.model.CIContent +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.GroupFeature +import chat.simplex.common.model.GroupFeatureEnabled import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.model.RcvGroupEvent import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.ModalManager +import org.junit.Assert.assertEquals import org.junit.Test class MemberInfoTest : UiTest() { @@ -25,4 +61,358 @@ class MemberInfoTest : UiTest() { cath.eventually("#team: alice removed bob from the group (signed)", CONNECT_TIMEOUT_MS) poll(what = "bob no longer a current member") { chatModel.groupMembers.value.none { it.memberProfile.displayName == "bob" && it.memberCurrent } } } + + @Test fun memberInfo_changeRoleToObserverCannotSend() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val info = ownGroup(alice, "team", bob).groupInfo() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob listed in team's members without a role label") { info.memberTexts("bob").let { "bob" in it && "observer" !in it } } + val member = info.member("bob") + member.pickRole(GroupMemberRole.Observer) + check(seesText("The role will be changed to \"observer\". Everyone in the group will be notified.")) { "the `Change role?` alert does not name `observer`" } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the role picker back at `member` after dismissing `Change role?`") { + !seesText("Change role?") && "member" in textsOf(Ui.MemberInfo.Role) && "observer" !in textsOf(Ui.MemberInfo.Role) + } + member.changeRole(GroupMemberRole.Observer) + bob.expectsMessage("#team: alice changed your role from member to observer (signed)", CONNECT_TIMEOUT_MS) + escape() + poll(what = "bob listed as `observer` in group info") { !exists(Ui.MemberInfo.Role) && "observer" in info.memberTexts("bob") } + bob sends "#team hi" + bob expects "#team: you don't have permission to send messages" + } + + @Test fun memberInfo_blockForMeHidesMessages() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = memberOfHostedGroup(alice, bob, cath) + cath sends "#team before block" + bob.eventuallyMessage("#team cath> before block", CONNECT_TIMEOUT_MS) + val before = chat.itemId("cath's `before block`") { it.content.text == "before block" } + var info = chat.groupInfo() + poll(what = "cath listed in team's members, not blocked") { info.memberTexts("cath").let { "cath" in it && "blocked" !in it } } + info.member("cath").block("cath") + poll(what = "cath's member info offering `Unblock member`") { exists(Ui.MemberInfo.Unblock) && !exists(Ui.MemberInfo.Block) } + escape() + poll(what = "member info to close to group info with cath listed as `blocked`") { !exists(Ui.MemberInfo.Unblock) && "blocked" in info.memberTexts("cath") } + info.close() + chat.send("after block") + bob.expectsMessage("#team alice> after block", CONNECT_TIMEOUT_MS) + cath.expectsMessage("#team alice> after block", CONNECT_TIMEOUT_MS) + cath sends "#team while blocked" + bob.expectsMessage("#team cath> while blocked", CONNECT_TIMEOUT_MS) + val hidden = chat.itemId("cath's `while blocked`") { it.content.text == "while blocked" } + poll(what = "cath's `while blocked` shown as `blocked`, her `before block` unchanged") { + textsOf(Ui.Chat.Item(hidden)).let { t -> "blocked" in t && t.none { "while blocked" in it } } && textsOf(Ui.Chat.Item(before)).any { "before block" in it } + } + info = chat.groupInfo() + info.member("cath").unblock("cath") + poll(what = "cath's member info offering `Block member` again") { exists(Ui.MemberInfo.Block) && !exists(Ui.MemberInfo.Unblock) } + escape() + poll(what = "member info to close to group info with cath not blocked") { !exists(Ui.MemberInfo.Block) && "blocked" !in info.memberTexts("cath") } + info.close() + cath sends "#team after unblock" + bob.expectsMessage("#team cath> after unblock", CONNECT_TIMEOUT_MS) + val shown = chat.itemId("cath's `after unblock`") { it.content.text == "after unblock" } + poll(what = "cath's `after unblock` shown with its text") { textsOf(Ui.Chat.Item(shown)).let { t -> t.any { "after unblock" in it } && "blocked" !in t } } + chat.send("end") + cath.expectsMessage("#team alice> end", CONNECT_TIMEOUT_MS) + } + + @Test fun memberInfo_unblockForAll() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = ownGroupWithTwo(alice, bob, cath) + blockForAllViaApi("team", "bob") + cath.eventually("#team: alice blocked bob (signed)", CONNECT_TIMEOUT_MS) + bob sends "#team while blocked" + val blocked = chat.itemId("bob's `while blocked`") { it.content.text == "while blocked" } + poll(what = "bob's `while blocked` shown as `blocked by admin`") { textsOf(Ui.Chat.Item(blocked)).let { t -> "blocked by admin" in t && t.none { "while blocked" in it } } } + val info = chat.groupInfo() + val member = info.member("bob") + poll(what = "bob's member info offering `Unblock for all`, not `Block for all`") { exists(Ui.MemberInfo.UnblockForAll) && !exists(Ui.MemberInfo.BlockForAll) } + member.unblockForAll("bob") + cath.expectsMessage("#team: alice unblocked bob (signed)", CONNECT_TIMEOUT_MS) + poll(what = "bob's member info offering `Block for all` again") { exists(Ui.MemberInfo.BlockForAll) && !exists(Ui.MemberInfo.UnblockForAll) } + escape() + poll(what = "member info to close to group info") { !exists(Ui.MemberInfo.Remove) } + info.close() + bob sends "#team after unblock" + cath.expectsMessage("#team bob> after unblock", CONNECT_TIMEOUT_MS) + val after = chat.itemId("bob's `after unblock`") { it.content.text == "after unblock" } + poll(what = "bob's `after unblock` shown with its text") { textsOf(Ui.Chat.Item(after)).let { t -> t.any { "after unblock" in it } && "blocked by admin" !in t } } + } + + @Test fun memberInfo_removeMemberWithMessages() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = ownGroupWithTwo(alice, bob, cath) + bob sends "#team bob was here" + cath.eventuallyMessage("#team bob> bob was here", CONNECT_TIMEOUT_MS) + val msg = chat.itemId("bob's `bob was here`") { it.content.text == "bob was here" } + val info = chat.groupInfo() + info.member("bob").removeWithMessages() + bob.expectsMessage("#team: alice removed you from the group with all messages (signed)", CONNECT_TIMEOUT_MS) + bob expects "use /d #team to delete the group" + cath.expectsMessage("#team: alice removed bob from the group with all messages (signed)", CONNECT_TIMEOUT_MS) + poll(what = "member info to close to group info after removing bob") { !exists(Ui.MemberInfo.Remove) && exists(Ui.GroupInfo.Preferences) } + info.close() + poll(what = "bob's `bob was here` shown as `moderated by alice`") { textsOf(Ui.Chat.Item(msg)).let { t -> "moderated by alice" in t && t.none { "bob was here" in it } } } + } + + @Test fun memberInfo_messageOpensKnownContact() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val team = ownGroup(alice, "team", bob) + val teamId = alice.chatList.chatId("team") + val bobId = alice.chatList.chatId("bob") + poll(what = "team's chat open") { chatModel.chatId.value == teamId } + team.groupInfo().member("bob").message() + poll(what = "bob's direct chat open with group and member info closed") { chatModel.chatId.value == bobId && !ModalManager.end.hasModalsOpen() } + check(chatModel.chats.value.count { (it.chatInfo as? ChatInfo.Direct)?.contact?.displayName == "bob" } == 1) { "`message` created a second direct chat with bob" } + ChatScreen(this).send("hi from member info") + bob.eventuallyMessage("alice> hi from member info", CONNECT_TIMEOUT_MS) + } + + @Test fun memberInfo_verifyCodeMatchesCli() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val info = ownGroup(alice, "team", bob).groupInfo() + memberConnected(info, "bob") + fun verified() = chatModel.groupMembers.value.first { it.memberProfile.displayName == "bob" }.verified + info.member("bob") + memberConnectionLoaded() + poll(what = "`Verify security code` in bob's member info with bob not verified") { + textOf(Ui.Info.VerifyCode) == "Verify security code" && !verified() + } + val shown = shownSecurityCode() + poll(what = "`bob is not verified` with `Mark verified`") { seesText("bob is not verified") && exists(Ui.VerifyCode.Mark) } + bob sends "/code #team alice" + bob.eventually("#team alice security code:") + assertEquals(bob.nextLine().filterNot(Char::isWhitespace), shown) + click(Ui.VerifyCode.Mark) + poll(what = "the security code screen closed with bob verified and `View security code` in his member info") { + !exists(Ui.VerifyCode.Mark) && verified() && exists(Ui.Info.VerifyCode) && textOf(Ui.Info.VerifyCode) == "View security code" + } + click(Ui.Info.VerifyCode) + poll(what = "`bob is verified` with `Clear verification`") { + seesText("bob is verified") && exists(described("Clear verification")) && !exists(Ui.VerifyCode.Mark) + } + click(described("Clear verification")) + poll(what = "`bob is not verified` and `Mark verified` again, with bob not verified") { + seesText("bob is not verified") && exists(Ui.VerifyCode.Mark) && !exists(described("Clear verification")) && !verified() + } + goBack() + poll(what = "`Verify security code` back in bob's member info") { + !exists(Ui.VerifyCode.Mark) && exists(Ui.Info.VerifyCode) && textOf(Ui.Info.VerifyCode) == "Verify security code" + } + } + + @Test fun memberInfo_changeRoleOfInvitedMember() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val info = emptyGroup(alice, "team").groupInfo() + val inv = inviteMembers() + poll(what = "bob offered") { inv.listed("bob") } + inv.role(GroupMemberRole.Admin) + poll(what = "`New member role` set to `admin`") { "admin" in inv.roleTexts() } + inv.toggle("bob") + inv.invite() + bob.expectsMessage("#team: alice invites you to join the group as admin", CONNECT_TIMEOUT_MS) + bob expects "use /j team to accept" + poll(what = "bob listed as an invited admin") { info.memberTexts("bob").let { "invited" in it && "admin" in it } } + val member = info.member("bob") + poll(what = "bob's role picker at `admin`") { exists(Ui.MemberInfo.Role) && "admin" in textsOf(Ui.MemberInfo.Role) } + member.pickRole(GroupMemberRole.Member) + check(seesText("The role will be changed to \"member\". The member will receive a new invitation.")) { + "the `Change role?` alert for an invited member does not say that a new invitation is sent" + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.expectsMessage("#team: alice invites you to join the group as member", CONNECT_TIMEOUT_MS) + bob expects "use /j team to accept" + closeMemberInfo("bob") + poll(what = "bob listed as an invited member without the `admin` label") { info.memberTexts("bob").let { "invited" in it && "admin" !in it } } + } + + @Test fun memberInfo_roleReadOnlyForHigherRole() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + connect(cath) + bob.createGroup("team") + bob sends "/a team alice admin" + bob.eventually("invitation to join the group #team sent to alice") + joinGroupFromList(alice, bob, "team") + backToChatList() + val info = alice.chatList.open("team").groupInfo() + poll(what = "alice an admin of team") { groupInfoOf("team").membership.memberRole == GroupMemberRole.Admin } + info.member("bob") + poll(what = "the owner bob's role shown read-only, with no role picker and no `Remove member`") { + seesText("Role") && seesText("owner") && !exists(Ui.MemberInfo.Role) && !exists(Ui.MemberInfo.Remove) + } + closeMemberInfo("bob") + val inv = inviteMembers() + poll(what = "cath offered") { inv.listed("cath") } + click(Ui.InviteMembers.Role) + poll(what = "`New member role` offering roles up to `admin`, without `owner`") { + exists(Ui.InviteMembers.Role.option(GroupMemberRole.Admin)) && exists(Ui.InviteMembers.Role.option(GroupMemberRole.Observer)) && + !exists(Ui.InviteMembers.Role.option(GroupMemberRole.Owner)) + } + click(Ui.InviteMembers.Role.option(GroupMemberRole.Admin)) + poll(what = "`New member role` set to `admin`") { "admin" in inv.roleTexts() } + inv.toggle("cath") + inv.invite() + cath.expectsMessage("#team: alice invites you to join the group as admin", CONNECT_TIMEOUT_MS) + cath expects "use /j team to accept" + info.member("cath") + poll(what = "a role picker at `admin` for the invited cath") { exists(Ui.MemberInfo.Role) && "admin" in textsOf(Ui.MemberInfo.Role) } + click(Ui.MemberInfo.Role) + poll(what = "cath's roles offered up to `admin`, without `owner`") { + exists(Ui.MemberInfo.Role.option(GroupMemberRole.Member)) && exists(Ui.MemberInfo.Role.option(GroupMemberRole.Admin)) && + !exists(Ui.MemberInfo.Role.option(GroupMemberRole.Owner)) + } + click(Ui.MemberInfo.Role.option(GroupMemberRole.Admin)) + poll(what = "the role list closed without a `Change role?` alert") { !exists(Ui.MemberInfo.Role.option(GroupMemberRole.Member)) && !seesText("Change role?") } + } + + @Test fun memberInfo_blockedByAdminShownToMember() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = memberOfHostedGroup(alice, bob, cath) + cath sends "#team spam from cath" + bob.eventuallyMessage("#team cath> spam from cath", CONNECT_TIMEOUT_MS) + val spam = chat.itemId("cath's `spam from cath`") { it.content.text == "spam from cath" } + poll(what = "cath's message shown with its text") { textsOf(Ui.Chat.Item(spam)).let { t -> t.any { "spam from cath" in it } && "moderated by bob" !in t } } + bob sends "\\\\ #team @cath spam from cath" + bob.eventually("message marked deleted by you") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "cath's message shown as `moderated by bob`") { + textsOf(Ui.Chat.Item(spam)).let { t -> "moderated by bob" in t && t.none { "spam from cath" in it } } + } + val info = chat.groupInfo() + poll(what = "cath listed in team's members, not blocked") { info.memberTexts("cath").let { "cath" in it && "blocked" !in it } } + val cathId = openMemberMenu("cath") + poll(what = "a member's menu for cath with `Block member` and without the moderator actions") { + exists(Ui.GroupInfo.MemberMenu(cathId, "block")) && "Block member" in textsOf(Ui.GroupInfo.MemberMenu(cathId, "block")) && + !exists(Ui.GroupInfo.MemberMenu(cathId, "blockForAll")) && !exists(Ui.GroupInfo.MemberMenu(cathId, "remove")) + } + click(Ui.GroupInfo.MemberMenu(cathId, "block")) + confirmAlert("Block member?", "All new messages from cath will be hidden!", "Block") + poll(what = "cath listed as `blocked`") { "blocked" in info.memberTexts("cath") } + openMemberMenu("cath") + poll(what = "`Unblock member` in place of `Block member` in cath's menu") { + exists(Ui.GroupInfo.MemberMenu(cathId, "unblock")) && !exists(Ui.GroupInfo.MemberMenu(cathId, "block")) + } + click(Ui.GroupInfo.MemberMenu(cathId, "unblock")) + confirmAlert("Unblock member?", "Messages from cath will be shown!", "Unblock") + poll(what = "cath no longer listed as `blocked`") { "blocked" !in info.memberTexts("cath") } + info.member("cath") + poll(what = "`Block member` in cath's member info") { exists(Ui.MemberInfo.Block) && !seesText("Blocked by admin") } + bob sends "/block for all #team cath" + bob.eventually("#team: you blocked cath (signed)") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "cath's member info showing `Blocked by admin` in place of `Block member`") { + seesText("Blocked by admin") && !exists(Ui.MemberInfo.Block) && !exists(Ui.MemberInfo.Unblock) + } + closeMemberInfo("cath") + poll(what = "cath listed as `blocked`") { "blocked" in info.memberTexts("cath") } + openMemberMenu("cath") + check(!exists(Ui.GroupInfo.MemberMenu(cathId, "block")) && !exists(Ui.GroupInfo.MemberMenu(cathId, "unblock"))) { + "a member's menu offers blocking cath, who is blocked by admin" + } + } + + @Test fun memberInfo_callButtonsDirectMessagesProhibitedAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val info = emptyGroup(alice, "team").groupInfo() + check(groupInfoOf("team").fullGroupPreferences.directMessages.enable == GroupFeatureEnabled.OFF) { "direct messages are on in a new group" } + val link = info.createLink() + bob.joinsViaLink(link, "team") + memberConnected(info, "bob") + info.member("bob") + for (button in listOf(Ui.MemberInfo.Call, Ui.MemberInfo.Video)) { + click(button) + alertSaying("Can't call group member", "Direct messages between members are prohibited in this group.") + } + closeMemberInfo("bob") + groupInfoRow(Ui.GroupInfo.Preferences) + val prefs = GroupPreferencesScreen(this) + prefs.toggle(GroupFeature.DirectMessages) + prefs.save() + poll(what = "direct messages allowed in team") { groupInfoOf("team").fullGroupPreferences.directMessages.enable == GroupFeatureEnabled.ON } + prefs.backClosing() + info.member("bob") + for (button in listOf(Ui.MemberInfo.Call, Ui.MemberInfo.Video)) { + click(button) + alertSaying("Can't call group member", "Send message to enable calls.") + } + } + + @Test fun memberInfo_switchAddress() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = ownGroup(alice, "team", bob) + val info = chat.groupInfo() + memberConnected(info, "bob") + val smp = localSmpServer() + info.member("bob") + memberConnectionLoaded() + poll(what = "`Receiving via` and `Sending via` the local server, `Network status` and `Change receiving address` in bob's member info") { + exists(Ui.Info.SendingVia) && exists(described("Network status")) && exists(Ui.Info.SwitchAddress) && !seesText(ABORT_SWITCH) && + textsOf(Ui.Info.ReceivingVia).let { "Receiving via" in it && it.any { t -> smp.hostnames.first() in t } } && + textsOf(Ui.Info.SendingVia).let { "Sending via" in it && it.any { t -> smp.hostnames.first() in t } } + } + val receiving = copiedServer(Ui.Info.ReceivingVia) + check(isServer(receiving, smp)) { "`Receiving via` copied `$receiving`, not the local server $smp" } + val sending = copiedServer(Ui.Info.SendingVia) + check(isServer(sending, smp)) { "`Sending via` copied `$sending`, not the local server $smp" } + check(!seesText(NETWORK_STATUS_TEXT)) { "the network status explanation is shown before `Network status` is clicked" } + click(described("Network status")) + alertSaying("Network status", NETWORK_STATUS_TEXT) + click(Ui.Info.SwitchAddress) + poll(what = "the `Change receiving address?` alert with `Change`") { + seesText("Change receiving address?") && + seesText("Receiving address will be changed to a different server. Address change will complete after sender comes online.") && + "Change" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + val bobRow = Ui.GroupInfo.Member(groupMemberId("bob")) + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "member info closed to group info after `Change`") { !exists(Ui.Info.SwitchAddress) && exists(bobRow) } + bob.eventually("#team: alice started changing address for you", CONNECT_TIMEOUT_MS) + bob.eventually("#team: alice changed address for you", CONNECT_TIMEOUT_MS) + info.close() + chat.itemId("`you changed address for bob` in team") { it.content is CIContent.SndConnEventContent && it.content.text == "you changed address for bob" } + bob sends "#team after switch" + chat.itemId("bob's `after switch` on the new address") { it.content.text == "after switch" } + } + + @KnownAppIssue("15.5 item 21", "cath's loaded member info offering memberInfo.deleteMessages and not memberInfo.remove") + @Test fun memberInfo_deleteMessagesOfLeftMember() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = ownGroupWithTwo(alice, bob, cath) + bob sends "#team bob stays" + cath.eventuallyMessage("#team bob> bob stays", CONNECT_TIMEOUT_MS) + cath sends "#team cath was here" + bob.eventuallyMessage("#team cath> cath was here", CONNECT_TIMEOUT_MS) + val stays = chat.itemId("bob's `bob stays`") { it.content.text == "bob stays" } + val msg = chat.itemId("cath's `cath was here`") { it.content.text == "cath was here" } + cath sends "/leave #team" + cath.eventually("#team: you left the group") + bob.eventually("#team: cath left the group (signed)", CONNECT_TIMEOUT_MS) + val left = chat.itemId("cath's `left` event in team") { (it.content as? CIContent.RcvGroupEventContent)?.rcvGroupEvent is RcvGroupEvent.MemberLeft } + poll(what = "cath's `left` event shown, her message still shown with its text") { + textsOf(Ui.Chat.Item(left)).any { "left" in it } && textsOf(Ui.Chat.Item(msg)).any { "cath was here" in it } + } + click(Ui.Chat.Item(msg).part("avatar")) + awaitAppIdle("opening cath's member info") + poll(what = "cath's loaded member info offering ${Ui.MemberInfo.DeleteMessages.tag} and not ${Ui.MemberInfo.Remove.tag}") { + exists(Ui.MemberInfo.DeleteMessages) && !exists(Ui.MemberInfo.Remove) + } + click(Ui.MemberInfo.DeleteMessages) + confirmAlert("Delete member messages?", "Member messages will be deleted - this cannot be undone!", "Delete messages") + bob.eventually("#team: alice removed cath from the group with all messages (signed)", CONNECT_TIMEOUT_MS) + poll(what = "member info closed with cath's message shown as `moderated by alice` and bob's kept") { + !exists(Ui.MemberInfo.DeleteMessages) && !ModalManager.end.hasModalsOpen() && + textsOf(Ui.Chat.Item(msg)).let { t -> "moderated by alice" in t && t.none { "cath was here" in it } } && + textsOf(Ui.Chat.Item(stays)).any { "bob stays" in it } + } + } + + private companion object { + const val NETWORK_STATUS_TEXT = "You are connected to the server used to receive messages from this connection." + const val ABORT_SWITCH = "Abort changing address" + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberListTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberListTest.kt new file mode 100644 index 0000000000..6dbd5fc339 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberListTest.kt @@ -0,0 +1,149 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.confirmAlert +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.emptyGroup +import chat.simplex.app.ui.harness.groupMemberId +import chat.simplex.app.ui.harness.inviteMembers +import chat.simplex.app.ui.harness.membersTitleShown +import chat.simplex.app.ui.harness.openMemberMenu +import chat.simplex.app.ui.harness.ownGroupOfThree +import chat.simplex.app.ui.harness.scrollToMembersEnd +import chat.simplex.app.ui.harness.selectMembers +import chat.simplex.app.ui.harness.selectionEnded +import chat.simplex.app.ui.harness.setRoleViaApi +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.ui.Ui +import org.junit.Test + +private val ROLE_LABELS = listOf("owner", "admin", "moderator", "observer", "member") + +class MemberListTest : UiTest() { + @Test fun memberList_rolesAndStatuses() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + connect(cath) + val info = emptyGroup(alice, "team").groupInfo() + poll(what = "`1 members` with alice's own row `you: creator`, `owner`") { + info.ownMemberTexts().let { "you: creator" in it && "owner" in it } && membersTitleShown(1) + } + val first = inviteMembers() + poll(what = "cath offered") { first.listed("cath") } + first.toggle("cath") + first.invite() + cath.expectsMessage("#team: alice invites you to join the group as member", CONNECT_TIMEOUT_MS) + cath expects "use /j team to accept" + val second = inviteMembers() + poll(what = "bob offered") { second.listed("bob") } + second.role(GroupMemberRole.Admin) + poll(what = "`New member role` set to `admin`") { "admin" in second.roleTexts() } + second.toggle("bob") + second.invite() + bob.expectsMessage("#team: alice invites you to join the group as admin", CONNECT_TIMEOUT_MS) + bob expects "use /j team to accept" + poll(what = "`3 members` once bob and cath are invited") { info.ownMemberTexts().isNotEmpty() && membersTitleShown(3) && !membersTitleShown(1) } + cath sends "/j team" + cath.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + bob sends "/j team" + bob.eventually("#team: you joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob `connected` as `admin`, cath `connected` without a role label") { + info.memberTexts("bob").let { "connected" in it && "admin" in it } && + info.memberTexts("cath").let { t -> "connected" in t && ROLE_LABELS.none { it in t } } + } + poll(what = "the admin bob listed above cath, who was invited first") { + textsOf(Ui.GroupInfo.Content).let { t -> t.indexOf("bob") >= 0 && t.indexOf("bob") < t.indexOf("cath") } + } + } + + @KnownAppIssue("15.5 item 15", "FOREIGN KEY constraint failed") + @Test fun memberSelection_blockUnblockAndRemoveMultiple() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath"); val dan = cli("dan") + var info = ownGroupOfThree(alice, bob, cath, dan).groupInfo() + poll(what = "`4 members` in team's info") { info.ownMemberTexts().isNotEmpty() && membersTitleShown(4) } + val cathId = groupMemberId("cath") + scrollToMembersEnd() + val bobId = openMemberMenu("bob") + poll(what = "bob's menu with `Block for all`, `Remove member` and `Select`") { + listOf("blockForAll", "remove", "select").all { exists(Ui.GroupInfo.MemberMenu(bobId, it)) } && !exists(Ui.GroupInfo.MemberMenu(bobId, "unblockForAll")) && + listOf("Block for all", "Remove member", "Select").all { seesText(it) } + } + click(Ui.GroupInfo.MemberMenu(bobId, "select")) + poll(what = "`Selected 1` after `Select` on bob") { ChatScreen(this).selectionTitle() == "Selected 1" } + click(Ui.GroupInfo.Member(cathId)) + poll(what = "`Selected 2` after tapping cath") { ChatScreen(this).selectionTitle() == "Selected 2" } + click(Ui.GroupInfo.MembersToolbar.part("block")) + confirmAlert("Block members for all?", "All new messages from these members will be hidden!", "Block for all") + dan.eventuallyAll(lines = listOf("#team: alice blocked bob (signed)", "#team: alice blocked cath (signed)"), timeoutMs = CONNECT_TIMEOUT_MS) + selectionEnded() + poll(what = "bob and cath listed as `blocked`, dan not") { + "blocked" in info.memberTexts("bob") && "blocked" in info.memberTexts("cath") && "blocked" !in info.memberTexts("dan") + } + openMemberMenu("bob") + poll(what = "bob's menu offering `Unblock for all` instead of `Block for all`") { + exists(Ui.GroupInfo.MemberMenu(bobId, "unblockForAll")) && !exists(Ui.GroupInfo.MemberMenu(bobId, "blockForAll")) + } + click(Ui.GroupInfo.MemberMenu(bobId, "select")) + poll(what = "`Selected 1` after `Select` on the blocked bob") { ChatScreen(this).selectionTitle() == "Selected 1" } + click(Ui.GroupInfo.Member(cathId)) + poll(what = "`Selected 2` after tapping the blocked cath") { ChatScreen(this).selectionTitle() == "Selected 2" } + click(Ui.GroupInfo.MembersToolbar.part("block")) + confirmAlert("Unblock members for all?", "Messages from these members will be shown!", "Unblock for all") + dan.eventuallyAll(lines = listOf("#team: alice unblocked bob (signed)", "#team: alice unblocked cath (signed)"), timeoutMs = CONNECT_TIMEOUT_MS) + selectionEnded() + poll(what = "bob and cath no longer `blocked`") { "blocked" !in info.memberTexts("bob") && "blocked" !in info.memberTexts("cath") } + selectMembers("bob", "cath") + click(Ui.GroupInfo.MembersToolbar.part("remove")) + val remove = Ui.Alert.Option(Ui.AlertSource.Shared, "remove") + poll(what = "the `Remove members?` alert with `Remove` and `Remove and delete messages`") { + seesText("Remove members?") && seesText("Members will be removed from group - this cannot be undone!") && + exists(remove) && "Remove" in textsOf(remove) && seesText("Remove and delete messages") + } + click(remove) + bob.eventually("#team: alice removed you from the group (signed)", CONNECT_TIMEOUT_MS) + cath.eventually("#team: alice removed you from the group (signed)", CONNECT_TIMEOUT_MS) + dan.eventuallyAll(lines = listOf("#team: alice removed bob from the group (signed)", "#team: alice removed cath from the group (signed)"), timeoutMs = CONNECT_TIMEOUT_MS) + selectionEnded() + info.close() + info = ChatScreen(this).groupInfo() + poll(what = "`2 members` with dan listed and bob and cath gone") { + info.ownMemberTexts().isNotEmpty() && membersTitleShown(2) && "dan" in info.memberTexts("dan") && + !exists(Ui.GroupInfo.Member(bobId)) && !exists(Ui.GroupInfo.Member(cathId)) + } + } + + @Test fun memberSelection_selectAllExcludesModerators() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath"); val dan = cli("dan") + val chat = ownGroupOfThree(alice, bob, cath, dan) + setRoleViaApi("team", "dan", GroupMemberRole.Moderator) + dan.eventually("#team: alice changed your role from member to moderator (signed)", CONNECT_TIMEOUT_MS) + val info = chat.groupInfo() + poll(what = "dan listed as `moderator`, bob and cath without a role label") { + "moderator" in info.memberTexts("dan") && "moderator" !in info.memberTexts("bob") && "moderator" !in info.memberTexts("cath") + } + scrollToMembersEnd() + selectMembers("bob") + click(Ui.GroupInfo.Member(groupMemberId("dan"))) + check(ChatScreen(this).selectionTitle() == "Selected 1") { "tapping the moderator dan changed the selection to `${ChatScreen(this).selectionTitle()}`" } + click(Ui.Chat.Selection.part("selectAll")) + poll(what = "`Selected 2` (bob and cath) after select all, the moderator dan left out") { ChatScreen(this).selectionTitle() == "Selected 2" } + click(Ui.GroupInfo.MembersToolbar.part("role")) + confirmAlert("Change role?", "The role will be changed to \"observer\". Everyone in the group will be notified.", "Change") + bob.eventually("#team: alice changed your role from member to observer (signed)", CONNECT_TIMEOUT_MS) + cath.eventually("#team: alice changed your role from member to observer (signed)", CONNECT_TIMEOUT_MS) + dan.eventuallyAll( + lines = listOf("#team: alice changed the role of bob from member to observer (signed)", "#team: alice changed the role of cath from member to observer (signed)"), + timeoutMs = CONNECT_TIMEOUT_MS, + ) + selectionEnded() + poll(what = "bob and cath listed as `observer`, dan still `moderator`") { + "observer" in info.memberTexts("bob") && "observer" in info.memberTexts("cath") && info.memberTexts("dan").let { "moderator" in it && "observer" !in it } + } + info.close() + chat.send("done") + dan.expectsMessage("#team alice> done", CONNECT_TIMEOUT_MS) + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MentionNotificationTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MentionNotificationTest.kt new file mode 100644 index 0000000000..a7d91a505f --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MentionNotificationTest.kt @@ -0,0 +1,48 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.chatListToolbar +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.quiesce +import chat.simplex.common.model.MsgFilter +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class MentionNotificationTest : UiTest() { + @Test fun mentions_mentionsOnlyMuteCountsOnlyMentionsForOtherProfile() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + backToChatList() + val info = alice.chatList.open("team").groupInfo() + waitFor(Ui.GroupInfo.Mute) + click(Ui.GroupInfo.Mute) + poll(what = "team muted to mentions only") { groupInfoOf("team").chatSettings.enableNtfs == MsgFilter.Mentions && seesText("Mute all") } + info.close() + backToChatList() + alice.userPicker().profiles().apply { add("work"); close() } + quiesce() + fun aliceUser() = chatModel.users.first { it.user.displayName == "alice" } + val before = aliceUser().unreadCount + bob sends "#team @alice one" + bob sends "#team plain" + bob sends "#team @alice two" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "both mentions counted for the inactive profile alice and the badge on the profile button") { + aliceUser().unreadCount >= before + 2 && chatListToolbar().profileBadge() + } + check(aliceUser().unreadCount == before + 2) { "alice's unread count is ${aliceUser().unreadCount}, expected ${before + 2}: the plain message in the mentions-only group was counted" } + val picker = alice.userPicker() + poll(what = "alice's row in the user picker showing ${before + 2}") { "${before + 2}" in textsOf(Ui.UserPicker.User(aliceUser().user.userId)) } + picker.switchTo("alice") + val chat = alice.chatList.open("team") + chat.itemId("bob's `plain` in team") { it.content.text == "plain" } + chat.itemId("bob's `@alice two` in team") { it.content.text == "@alice two" } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ModerationTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ModerationTest.kt new file mode 100644 index 0000000000..919447e5dc --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ModerationTest.kt @@ -0,0 +1,36 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.ownGroupWithTwo +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ModerationTest : UiTest() { + @Test fun moderate_multipleSelected() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = ownGroupWithTwo(alice, bob, cath) + bob sends "#team spam one" + cath.eventuallyMessage("#team bob> spam one", CONNECT_TIMEOUT_MS) + cath sends "#team spam two" + bob.eventuallyMessage("#team cath> spam two", CONNECT_TIMEOUT_MS) + val one = chat.itemId("bob's `spam one`") { it.content.text == "spam one" } + val two = chat.itemId("cath's `spam two`") { it.content.text == "spam two" } + chat.select("spam one") + chat.toggle(two) + poll(what = "`Selected 2` in the selection toolbar") { chat.selectionTitle() == "Selected 2" } + click(Ui.Chat.Selection.part("moderate")) + poll(what = "the `Delete 2 messages of members?` alert saying they will be marked as moderated, with `Delete`") { + seesText("Delete 2 messages of members?") && seesText("The messages will be marked as moderated for all members.") && + "Delete" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.expectsMessage("#team bob> [marked deleted by alice] spam one", CONNECT_TIMEOUT_MS) + bob.expectsMessage("#team cath> [marked deleted by alice] spam two", CONNECT_TIMEOUT_MS) + cath.expectsMessage("#team bob> [marked deleted by alice] spam one", CONNECT_TIMEOUT_MS) + cath.expectsMessage("#team cath> [marked deleted by alice] spam two", CONNECT_TIMEOUT_MS) + poll(what = "both messages collapsed into `2 messages moderated by alice` and selection mode ended") { + "2 messages moderated by alice" in textsOf(Ui.Chat.Item(two)) && !exists(Ui.Chat.Item(one)) && !exists(Ui.Chat.Selection.part("title")) + } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ModeratorReportTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ModeratorReportTest.kt new file mode 100644 index 0000000000..6905f17bc6 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/ModeratorReportTest.kt @@ -0,0 +1,127 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.openReportsFromBanner +import chat.simplex.app.ui.harness.ownGroupWithTwo +import chat.simplex.app.ui.harness.receivesReport +import chat.simplex.app.ui.harness.sendsReport +import chat.simplex.app.ui.harness.reportsBanner +import chat.simplex.app.ui.harness.setRoleViaApi +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ModeratorReportTest : UiTest() { + @Test fun reports_moderatorBannerAndCounts() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = ownGroupWithTwo(alice, bob, cath) + cath sends "#team joke one" + cath sends "#team joke two" + bob.eventuallyMessage("#team cath> joke two", CONNECT_TIMEOUT_MS) + chat.itemId("cath's `joke two`") { it.content.text == "joke two" } + check(!exists(Ui.Chat.Reports)) { "a reports banner shown before any report" } + bob.sendsReport("team", "spam", "cath", "joke one") + reportsBanner("1 report") + bob.sendsReport("team", "community", "cath", "joke two") + reportsBanner("2 reports") + val info = chat.groupInfo() + poll(what = "`Member reports` with the count 2 in group info") { info.reportsTexts().let { "Member reports" in it && "2" in it } } + info.close() + val view = openReportsFromBanner() + val one = view.reportId("joke one") + val two = view.reportId("joke two") + check(view.texts(one).any { "Spam" in it } && view.texts(two).any { "Community guidelines violation" in it }) { + "the reports do not show their reasons: ${view.texts(one)}, ${view.texts(two)}" + } + view.menu(two, "delete") + val forMe = Ui.Alert.Option(Ui.AlertSource.Shared, "forMe") + poll(what = "the `Delete message?` alert for a report offering only `Delete for me`") { + seesText("Delete message?") && exists(forMe) && "Delete for me" in textsOf(forMe) && !exists(Ui.Alert.Option(Ui.AlertSource.Shared, "forEveryone")) + } + click(forMe) + poll(what = "the `joke two` report gone from `Member reports`, the `joke one` report kept") { + view.items().none { it.id == two } && !exists(Ui.Chat.Item(two)) && exists(Ui.Chat.Item(one)) + } + view.close() + reportsBanner("1 report") + chat.send("after delete") + bob.expectsMessage("#team alice> after delete", CONNECT_TIMEOUT_MS) + cath.expectsMessage("#team alice> after delete", CONNECT_TIMEOUT_MS) + } + + @Test fun reports_archiveForMeForAllModeratorsAndAll() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = ownGroupWithTwo(alice, bob, cath) + setRoleViaApi("team", "cath", GroupMemberRole.Moderator) + cath.eventually("#team: alice changed your role from member to moderator (signed)", CONNECT_TIMEOUT_MS) + bob.eventually("#team: alice changed the role of cath from member to moderator (signed)", CONNECT_TIMEOUT_MS) + cath sends "#team joke one" + cath sends "#team joke two" + cath sends "#team joke three" + bob.eventuallyMessage("#team cath> joke three", CONNECT_TIMEOUT_MS) + chat.itemId("cath's `joke three`") { it.content.text == "joke three" } + bob.sendsReport("team", "spam", "cath", "joke one") + cath.receivesReport("team", "bob", "cath", "joke one", "report spam") + bob.sendsReport("team", "community", "cath", "joke two") + cath.receivesReport("team", "bob", "cath", "joke two", "report community") + bob.sendsReport("team", "profile", "cath", "joke three") + cath.receivesReport("team", "bob", "cath", "joke three", "report profile") + reportsBanner("3 reports") + val view = openReportsFromBanner() + val one = view.reportId("joke one") + val two = view.reportId("joke two") + val three = view.reportId("joke three") + check(listOf(one, two, three).none { id -> view.texts(id).any { "archived report" in it } }) { "a report shown as archived before archiving" } + view.archive(one, "archiveForMe") + poll(what = "the `joke one` report shown as `archived report`") { view.texts(one).any { "archived report" in it } } + view.archive(three, "archiveForAll") + cath.expectsMessage("#team (support: bob) bob> [marked deleted by alice] report profile", CONNECT_TIMEOUT_MS) + poll(what = "the `joke three` report shown as `archived report`") { view.texts(three).any { "archived report" in it } } + check(view.texts(two).none { "archived report" in it }) { "the `joke two` report was archived too" } + view.close() + reportsBanner("1 report") + backToChatList() + alice.chatList.rowMenu("team", "archiveReports") + poll(what = "the `Archive all reports?` alert with `Archive`") { + seesText("Archive all reports?") && seesText("All reports will be archived for you.") && "Archive" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "no active reports left in team") { alice.chatList.chat("team").chatStats.reportsCount == 0 } + alice.chatList.open("team") + chat.itemId("joke three") + check(!exists(Ui.Chat.Reports)) { "the reports banner still shown after `Archive all reports`" } + chat.send("after archive") + cath.expectsMessage("#team alice> after archive", CONNECT_TIMEOUT_MS) + bob.expectsMessage("#team alice> after archive", CONNECT_TIMEOUT_MS) + } + + @Test fun reports_moderateReportedMessageMarksReport() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = ownGroupWithTwo(alice, bob, cath) + cath sends "#team bad joke" + bob.eventuallyMessage("#team cath> bad joke", CONNECT_TIMEOUT_MS) + val joke = chat.itemId("cath's `bad joke`") { it.content.text == "bad joke" } + bob.sendsReport("team", "spam", "cath", "bad joke") + reportsBanner("1 report") + chat.menu("bad joke", "moderate") + poll(what = "the `Delete member message?` alert with `Delete`") { + seesText("Delete member message?") && "Delete" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + cath.expectsMessage("#team cath> [marked deleted by alice] bad joke", CONNECT_TIMEOUT_MS) + bob.expectsMessage("#team cath> [marked deleted by alice] bad joke", CONNECT_TIMEOUT_MS) + bob expects "#team: 1 messages deleted by member alice" + poll(what = "`moderated by alice` in place of `bad joke` and the reports banner gone") { + textsOf(Ui.Chat.Item(joke)).let { t -> "moderated by alice" in t && t.none { "bad joke" in it } } && !exists(Ui.Chat.Reports) + } + val info = chat.groupInfo() + poll(what = "`Member reports` in group info without a count") { info.reportsTexts().let { "Member reports" in it && "1" !in it } } + val view = info.reports() + val report = view.reportId("bad joke") + poll(what = "the `bad joke` report shown as `archived report`") { view.texts(report).any { "archived report" in it } } + view.close() + info.close() + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/SupportChatTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/SupportChatTest.kt new file mode 100644 index 0000000000..588cf01e96 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/SupportChatTest.kt @@ -0,0 +1,203 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.CliPeer +import chat.simplex.app.ui.harness.MemberSupportScreen +import chat.simplex.app.ui.harness.SupportChatScreen +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.closeMemberInfo +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.expectsScopedMessage +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.groupInfoOf +import chat.simplex.app.ui.harness.groupMemberId +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.memberConnected +import chat.simplex.app.ui.harness.memberOfBobsTeamWithCath +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.openSupportRowMenu +import chat.simplex.app.ui.harness.ownTeamWithSupportRequests +import chat.simplex.app.ui.harness.supportBanner +import chat.simplex.app.ui.harness.supportRowBadge +import chat.simplex.app.ui.harness.unreadBadge +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.ModalManager +import org.junit.Test + +class SupportChatTest : UiTest() { + @Test fun supportChat_memberSendsToAdmins() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = memberOfBobsTeamWithCath(alice, bob, cath) + poll(what = "team's chat open with no support banner") { exists(Ui.Chat.Message) && supportBanner().isEmpty() } + bob sends "#team (support: alice) first" + bob sends "#team (support: alice) second" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `2 messages` banner in team's chat") { "2 messages" in supportBanner() } + val info = chat.groupInfo() + poll(what = "`Chat with admins` with 2 unread in group info") { + exists(Ui.GroupInfo.UserSupport) && "Chat with admins" in textsOf(Ui.GroupInfo.UserSupport) && unreadBadge(Ui.GroupInfo.UserSupport) == "2" + } + waitStable(Ui.GroupInfo.UserSupport) + click(Ui.GroupInfo.UserSupport) + val sc = SupportChatScreen(this) + sc.waitOpen() + sc.itemId("first") + sc.itemId("second") + poll(what = "bob's two support messages marked read in alice's support chat") { + sc.items().filter { it.content.text == "first" || it.content.text == "second" }.let { it.size == 2 && it.none { i -> i.isRcvNew } } + } + sc.send("hello") + bob.expectsScopedMessage("#team (support: alice) alice", "hello") + check(chat.itemTexts().none { it == "hello" || it == "first" || it == "second" }) { "a support message was added to team's main chat" } + sc.close() + info.close() + poll(what = "the support banner gone after the messages were read") { exists(Ui.Chat.Message) && supportBanner().isEmpty() } + bob sends "#team (support: alice) third" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `1 messages` banner in team's chat") { "1 messages" in supportBanner() } + click(Ui.Chat.SupportChats) + val again = SupportChatScreen(this) + again.waitOpen() + again.itemId("third") + again.itemId("hello") + again.close() + chat.send("for everyone") + cath.expectsMessage("#team alice> for everyone", CONNECT_TIMEOUT_MS) + bob.expectsMessage("#team alice> for everyone", CONNECT_TIMEOUT_MS) + } + + @Test fun chatsWithMembers_markRead() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + ownTeamWithSupportRequests(alice, bob, cath) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `2 chats with members` banner in team's chat") { supportBanner().any { it.startsWith("2 chat") } } + click(Ui.Chat.SupportChats) + MemberSupportScreen(this) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's and cath's support chats each with 1 unread") { supportRowBadge("bob") == "1" && supportRowBadge("cath") == "1" } + val bobId = openSupportRowMenu("bob") + click(Ui.MemberSupport.RowMenu(bobId, "markRead")) + poll(what = "bob's unread badge gone and cath's kept") { + exists(Ui.MemberSupport.Row(bobId)) && supportRowBadge("bob") == null && supportRowBadge("cath") == "1" + } + openSupportRowMenu("bob") + check(!exists(Ui.MemberSupport.RowMenu(bobId, "markRead"))) { "`Mark read` offered for bob's chat with no unread messages" } + click(Ui.MemberSupport.RowMenu(bobId, "delete")) + poll(what = "the `Delete chat with member?` alert with `Delete chat`") { + seesText("Delete chat with member?") && "Delete chat" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + val cathId = groupMemberId("cath") + poll(what = "bob's support chat gone from `Chats with members` and cath's kept") { + !exists(Ui.MemberSupport.Row(bobId)) && exists(Ui.MemberSupport.Row(cathId)) && supportRowBadge("cath") == "1" + } + goBack() + poll(what = "`Chats with members` closed to team's chat showing `1 chat`") { + !ModalManager.end.hasModalsOpen() && supportBanner().any { it.startsWith("1 chat") } + } + } + + @Test fun chatsWithMembers_listSearchAndOpen() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val chat = ownTeamWithSupportRequests(alice, bob, cath) + val info = chat.groupInfo() + val cwm = info.chatsWithMembers() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob and cath listed in `Chats with members`") { "bob" in cwm.rowTexts("bob") && "cath" in cwm.rowTexts("cath") } + typeInto(Ui.MemberSupport.Search, "bo") + poll(what = "only bob listed for `bo`") { "bob" in cwm.rowTexts("bob") && cwm.rowTexts("cath").isEmpty() } + replaceText(Ui.MemberSupport.Search, "cat") + poll(what = "only cath listed for `cat`") { "cath" in cwm.rowTexts("cath") && cwm.rowTexts("bob").isEmpty() } + hideKeyboard() + val sc = cwm.open("cath") + sc.itemId("cath needs help") + check(sc.items().none { it.content.text == "bob needs help" }) { "bob's support message is shown in cath's support chat" } + sc.send("hi cath") + cath.expectsMessage("#team (support) alice> hi cath", CONNECT_TIMEOUT_MS) + check(chat.itemTexts().none { it == "hi cath" }) { "alice's support reply was added to team's main chat" } + sc.close() + cwm.close() + info.close() + chat.send("for everyone") + bob.expectsMessage("#team alice> for everyone", CONNECT_TIMEOUT_MS) + cath.expectsMessage("#team alice> for everyone", CONNECT_TIMEOUT_MS) + } + + @Test fun chatsWithMembers_disabledState() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + backToChatList() + val info = alice.chatList.open("team").groupInfo() + fun supportOn() = groupInfoOf("team").fullGroupPreferences.support.on + poll(what = "`Chat with admins` and no `Chats with members` in a member's group info while support is on") { + supportOn() && exists(Ui.GroupInfo.Preferences) && exists(Ui.GroupInfo.UserSupport) && !exists(Ui.GroupInfo.MemberSupport) + } + bob.setsSupport("off") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Chat with admins` gone with support off, as alice has no support chat") { + !supportOn() && exists(Ui.GroupInfo.Preferences) && !exists(Ui.GroupInfo.UserSupport) + } + bob.setsSupport("on") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Chat with admins` back with support on") { supportOn() && exists(Ui.GroupInfo.UserSupport) } + waitStable(Ui.GroupInfo.UserSupport) + click(Ui.GroupInfo.UserSupport) + val sc = SupportChatScreen(this) + sc.waitOpen() + sc.send("help please") + bob.eventuallyMatching(Regex("(\\d{2}:\\d{2} )?#team \\(support: alice\\) alice!?> help please"), CONNECT_TIMEOUT_MS) + sc.close() + bob.setsSupport("off") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Chat with admins` kept with support off, as alice has a support chat") { + !supportOn() && exists(Ui.GroupInfo.Preferences) && exists(Ui.GroupInfo.UserSupport) + } + bob sends "/mr team alice moderator" + bob.eventuallyMatching(Regex("#team: you changed the role of alice to moderator( \\(signed\\))?")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Chats with members` in the group info of the moderator alice") { + groupInfoOf("team").membership.memberRole == GroupMemberRole.Moderator && exists(Ui.GroupInfo.MemberSupport) + } + info.chatsWithMembers() + poll(what = "`Chats with members are disabled`, not `No chats with members`") { + seesText("Chats with members are disabled") && !seesText("No chats with members") + } + } + + @Test fun supportChat_adminOpensFromMemberInfo() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = ownGroup(alice, "team", bob) + val info = chat.groupInfo() + memberConnected(info, "bob") + val member = info.member("bob") + poll(what = "`Chat with member` in the member bob's member info") { + exists(Ui.MemberInfo.SupportChat) && "Chat with member" in textsOf(Ui.MemberInfo.SupportChat) + } + member.changeRole(GroupMemberRole.Moderator) + bob.eventually("#team: alice changed your role from member to moderator (signed)", CONNECT_TIMEOUT_MS) + poll(what = "no `Chat with member` for the moderator bob, who has no support chat") { + "moderator" in textsOf(Ui.MemberInfo.Role) && !exists(Ui.MemberInfo.SupportChat) + } + member.changeRole(GroupMemberRole.Member) + bob.eventually("#team: alice changed your role from moderator to member (signed)", CONNECT_TIMEOUT_MS) + poll(what = "`Chat with member` back for the member bob") { "member" in textsOf(Ui.MemberInfo.Role) && exists(Ui.MemberInfo.SupportChat) } + closeMemberInfo("bob") + info.close() + poll(what = "team's chat with no support banner before bob's support message") { exists(Ui.Chat.Message) && supportBanner().isEmpty() } + bob sends "#team (support) bob needs help" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `1 chat` banner for bob's support message in team's chat") { + supportBanner().any { it.startsWith("1 chat") } + } + chat.groupInfo().member("bob") + click(Ui.MemberInfo.SupportChat) + val sc = SupportChatScreen(this) + sc.waitOpen() + sc.itemId("bob needs help") + sc.send("on it") + bob.eventuallyMessage("#team (support) alice> on it", CONNECT_TIMEOUT_MS) + check(chat.itemTexts().none { it == "on it" || it == "bob needs help" }) { "a support message was added to team's main chat" } + } + + private fun CliPeer.setsSupport(state: String) { + this sends "/set support #team $state" + eventually("updated group preferences:") + this expects "Chat with admins: $state" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/BusinessChatScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/BusinessChatScreens.kt new file mode 100644 index 0000000000..83318d8ee5 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/BusinessChatScreens.kt @@ -0,0 +1,30 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.BusinessChatType +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.ModalManager + +fun businessTypeOf(name: String): BusinessChatType? = + chatModel.chats.value.firstNotNullOfOrNull { c -> (c.chatInfo as? ChatInfo.Group)?.groupInfo?.takeIf { c.chatInfo.chatViewName == name }?.businessChat?.chatType } + +fun UiScope.createBusinessAddress(app: App): String { + val address = app.userPicker().address().create() + waitFor(Ui.Address.Business) + click(Ui.Address.Business) + poll(what = "a business address with auto-accept saved") { savedAddressSettings()?.let { it.businessAddress && it.autoAccept != null } == true } + AddressScreen(this).close() + return address +} + +fun UiScope.leaveBusinessChatFromInfo(name: String) { + groupInfoRow(Ui.GroupInfo.Leave) + confirmAlert("Leave chat?", "$name\n\nYou will stop receiving messages from this chat. Chat history will be preserved.", "Leave") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "group info to close after leaving `$name`") { !ModalManager.end.hasModalsOpen() } +} + +fun UiScope.deleteBusinessChatFromInfo(name: String) { + groupInfoRow(Ui.GroupInfo.Delete) + confirmAlert("Delete chat?", "$name\n\nChat will be deleted for all members - this cannot be undone!", "Delete") +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChannelScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChannelScreens.kt new file mode 100644 index 0000000000..105f8a5507 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChannelScreens.kt @@ -0,0 +1,97 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import kotlinx.coroutines.runBlocking + +class ChannelLinkScreen(private val s: UiScope, private val channel: String) { + init { + s.poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Channel link` screen of `$channel` with `Share link` and `Continue`") { + s.seesText("Channel link") && s.exists(Ui.GroupLink.Share) && s.exists(Ui.GroupLink.Continue) + } + } + + fun share(): String { + s.click(Ui.GroupLink.Share) + var link: String? = null + s.poll(what = "the channel link of `$channel` shared") { link = s.clipboardText()?.takeIf { it.isNotBlank() }; link != null } + return link!! + } + + fun continueToChannel(): ChatScreen { + s.click(Ui.GroupLink.Continue) + s.poll(what = "the `Channel link` screen to close with `$channel` open") { + !s.exists(Ui.GroupLink.Share) && chatModel.chatId.value != null && chatModel.chatId.value == channelChatId(channel) + } + return ChatScreen(s) + } +} + +fun channelChatId(name: String): String? = + chatModel.chats.value.singleOrNull { (it.chatInfo as? ChatInfo.Group)?.groupInfo?.let { g -> g.useRelays && g.groupProfile.displayName == name } == true }?.id + +fun UiScope.createChannel(app: App, name: String): ChannelLinkScreen { + app.newChat() + click(Ui.NewChat.CreateChannel) + typeInto(Ui.NewChannel.Name, name) + click(Ui.NewChannel.Create) + return ChannelLinkScreen(this, name) +} + +fun channelLinkViaApi(name: String): String { + val groupId = groupInfoOf(name).groupId + val link = runBlocking { chatController.apiGetGroupLink(null, groupId) } ?: error("apiGetGroupLink failed for $name") + return link.connLinkContact.simplexChatUri(short = true) +} + +fun CliPeer.joinChannel(channel: String, link: String, vararg relays: CliPeer) { + this sends "/c $link" + this expects "#$channel: connection started" + eventuallyAll( + relays.flatMap { listOf("#$channel: joining the group (connecting to relay ${it.name})...", "#$channel: you joined the group (connected to relay ${it.name})") }, + timeoutMs = CONNECT_TIMEOUT_MS + ) + for (r in relays) { + r.eventually("$name: accepting request to join group #$channel...", CONNECT_TIMEOUT_MS) + r.eventually("#$channel: $name joined the group", CONNECT_TIMEOUT_MS) + } +} + +fun CliPeer.createChannel(channel: String, relay: CliPeer): String { + this sends "/relays name=${relay.name} ${relay.relayAddress}" + this expects "ok" + this sends "/public group relays=1 #$channel" + this expects "group #$channel is created" + this expects "wait for selected relay(s) to join, then you can invite members via group link" + eventually(" - relay id 1: active", CONNECT_TIMEOUT_MS) + this expects "group link:" + val link = nextLine() + relay.eventually("#$channel: you joined the group as relay", CONNECT_TIMEOUT_MS) + return link +} + +fun UiScope.openPreparedChannelFromSearch(link: String, name: String): ChatScreen { + enterLinkInSearch(link) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the plan alert for channel `$name` with `Open channel`") { + exists(planConfirm) && "Open channel" in textsOf(planConfirm) && seesText(name) + } + click(planConfirm) + poll(what = "`$name`'s prepared channel open") { + val id = channelChatId(name) + id != null && id == chatModel.chatId.value && chatModel.chats.value.first { it.id == id }.chatInfo.nextConnectPrepared + } + return ChatScreen(this) +} + +fun UiScope.joinPreparedChannel(name: String, owner: CliPeer, relay: CliPeer) { + poll(what = "`Join channel` in place of the message field") { exists(Ui.Chat.Connect) && "Join channel" in textsOf(Ui.Chat.Connect) } + click(Ui.Chat.Connect) + val me = chatModel.currentUser.value!!.displayName + owner.eventually("#$name: ${relay.name} introduced $me in the channel", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the message field in place of `Join channel` in `$name`") { + !exists(Ui.Chat.Connect) && exists(Ui.Chat.Message) && channelChatId(name)?.let { id -> chatModel.chats.value.first { it.id == id }.chatInfo.nextConnectPrepared } == false + } + quiesce() +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatItemActionsScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatItemActionsScreens.kt new file mode 100644 index 0000000000..933bbfe988 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatItemActionsScreens.kt @@ -0,0 +1,51 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.CIFile +import chat.simplex.common.model.MsgReaction +import chat.simplex.common.model.readCryptoFile +import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.getLoadedFilePath +import chat.simplex.common.ui.Ui +import java.io.File + +fun UiScope.openAttachmentSheet() { + hideKeyboard() + click(described("Attach")) + poll(what = "the attachment sheet over the chat with Image, Video and File") { + seesDescription("Close sheet") && exists(Ui.Chat.Attach("image")) && exists(Ui.Chat.Attach("video")) && exists(Ui.Chat.Attach("file")) + } +} + +fun UiScope.attachFile(file: File) { + openAttachmentSheet() + openFile(file) { click(Ui.Chat.Attach("file")) } + poll(what = "the preview of ${file.name} with `Cancel file preview`") { seesDescription("Cancel file preview") && seesText(file.name) } +} + +fun UiScope.closeItemMenu(id: Long) { + click(Ui.Chat.ItemMenu(id, "select")) + waitFor(Ui.Chat.Selection.part("title")) + escape() + poll(what = "selection mode to end") { !exists(Ui.Chat.Selection.part("title")) } +} + +fun UiScope.offeredReactions(id: Long): List { + rightClick(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "reply")) + val offered = MsgReaction.supported.map { it.text }.filter { exists(Ui.Chat.ItemReaction(id, it)) } + closeItemMenu(id) + return offered +} + +fun itemFileOf(id: Long): CIFile? = chatModel.chatsContext.chatItems.value.firstOrNull { it.id == id }?.file + +fun storedBytesOf(file: CIFile): ByteArray { + val path = getLoadedFilePath(file) ?: throw AssertionError("${file.fileName} is not on disk (status ${file.fileStatus})") + return File(path).readBytes() +} + +fun plainBytesOf(file: CIFile): ByteArray { + val path = getLoadedFilePath(file) ?: throw AssertionError("${file.fileName} is not on disk (status ${file.fileStatus})") + val args = file.fileSource?.cryptoArgs + return if (args != null) readCryptoFile(path, args) else File(path).readBytes() +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListFilterScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListFilterScreens.kt index 8f56a2445c..b2e0208da1 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListFilterScreens.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListFilterScreens.kt @@ -77,11 +77,15 @@ fun UiScope.businessChat(biz: CliPeer) { quiesce() } -fun CliPeer.createAddress(business: Boolean = false): String { +fun CliPeer.createAddress(business: Boolean = false, autoAccept: Boolean = true): String { this sends "/ad" this expects "Your new chat address is created!" this expects "" val link = nextLine() + if (!autoAccept) { + eventually("to delete it: /da (accepted contacts will remain connected)") + return link + } this sends (if (business) "/auto_accept on business" else "/auto_accept on") eventually(if (business) "auto_accept on, business" else "auto_accept on") return link @@ -95,8 +99,6 @@ fun CliPeer.createGroupLink(group: String): String { return nextLine() } -val planConfirm = Ui.Alert.Confirm(Ui.AlertSource.PrivacySensitive) - fun UiScope.enterLinkInSearch(link: String) = enterText(Ui.ChatList.Search, link) fun UiScope.searchShowsLink(description: String): Boolean = diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatMessagesScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatMessagesScreens.kt new file mode 100644 index 0000000000..3f85f25212 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatMessagesScreens.kt @@ -0,0 +1,29 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.ui.Ui + +private const val BOT_COMMANDS = + """[{"type":"command","keyword":"help","label":"Help"},{"type":"command","keyword":"order","label":"Order","params":""},""" + + """{"type":"menu","label":"More","commands":[{"type":"command","keyword":"status","label":"Status"},{"type":"command","keyword":"cost","label":"Cost"}]}]""" +private val BOT_COMMANDS_FOOTER = Regex("use /set bot commands .* or /delete bot commands") + +const val PREVIEW_IMAGE_DATA = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=" + +fun CliPeer.becomeBotWithCommands() { + this sends """/_profile 1 {"displayName":"$name","fullName":"","peerType":"bot","preferences":{"commands":$BOT_COMMANDS}}""" + this sends "/u" + eventually("user profile: $name (bot)") + this expects "use /p [] to change it" + this expects "Bot commands:" + eventuallyMatching(BOT_COMMANDS_FOOTER) +} + +fun UiScope.openItemInfo(id: Long, title: String): ItemInfoScreen { + itemMenu(id, "info") + poll(what = "the item info titled `$title`") { seesText(title) } + return ItemInfoScreen(this) +} + +fun UiScope.commandShown(keyword: String, label: String): Boolean = + exists(Ui.Chat.Command(keyword)) && textsOf(Ui.Chat.Command(keyword)).let { label in it && "/$keyword" in it } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatViewScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatViewScreens.kt new file mode 100644 index 0000000000..1b73c8f8f3 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatViewScreens.kt @@ -0,0 +1,30 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatItem +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey + +fun contentFilter(name: String): UiKey = Ui.Chat.ContentFilter.option(name) + +fun UiScope.pickContentFilter(name: String) { + click(Ui.Chat.ContentFilter) + click(contentFilter(name)) +} + +fun loadedItemIds(): Set = chatModel.chatsContext.chatItems.value.map { it.id }.toSet() + +fun UiScope.loadedItemId(what: String, match: (ChatItem) -> Boolean): Long { + var id: Long? = null + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = what) { + id = chatModel.chatsContext.chatItems.value.lastOrNull(match)?.id + id != null + } + return id!! +} + +fun UiScope.closeChatSearch() { + hideKeyboard() + goBack() + poll(what = "the chat search to close") { !exists(Ui.Chat.Bar.part("search")) } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ConnectScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ConnectScreens.kt new file mode 100644 index 0000000000..12e541393a --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ConnectScreens.kt @@ -0,0 +1,64 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.MsgContent +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui + +val planConfirm = Ui.Alert.Confirm(Ui.AlertSource.PrivacySensitive) + +val planDismiss = Ui.Alert.Dismiss(Ui.AlertSource.PrivacySensitive) + +fun planOption(name: String) = Ui.Alert.Option(Ui.AlertSource.PrivacySensitive, name) + +fun UiScope.openPasteLink(app: App) { + app.newChat() + click(Ui.NewChat.PasteLink) + poll(what = "the `Paste the link you received` section with `Tap to paste link`") { + seesText("Paste the link you received") && exists(Ui.Link.Paste) && "Tap to paste link" in textsOf(Ui.Link.Paste) + } +} + +fun UiScope.enterLinkInNewChatSearch(app: App, link: String) { + app.newChat() + enterText(Ui.NewChat.Search, link) +} + +fun UiScope.openNewChatFromSearch(link: String, name: String): ChatScreen { + enterLinkInSearch(link) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the plan alert for `$name` with `Open new chat`") { + exists(planConfirm) && "Open new chat" in textsOf(planConfirm) && seesText(name) + } + click(planConfirm) + poll(what = "`$name`'s prepared chat open") { + chatModel.chats.value.firstOrNull { it.chatInfo.chatViewName == name && it.chatInfo.nextConnectPrepared }?.id?.let { it == chatModel.chatId.value } == true + } + return ChatScreen(this) +} + +fun UiScope.chatLinkItemId(): Long { + var id: Long? = null + poll(what = "a chat link card in the open chat") { + id = chatModel.chatsContext.chatItems.value.lastOrNull { it.content.msgContent is MsgContent.MCChat }?.id + id != null && exists(Ui.Chat.Item(id!!)) + } + return id!! +} + +fun CliPeer.createFullOneTimeLink(): String { + this types "/c" + eventually("The invitation link for old clients:") + return nextLine() +} + +fun CliPeer.createFullAddress(): String { + this sends "/ad" + this expects "Your new chat address is created!" + return eventuallyMatching(Regex("The contact link for old clients: (\\S+)")).groupValues[1] +} + +fun CliPeer.becomeBot() { + this sends """/_profile 1 {"displayName":"$name","fullName":"","peerType":"bot"}""" + this sends "/u" + eventually("user profile: $name (bot)") + this expects "use /p [] to change it" +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ContactDetailsScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ContactDetailsScreens.kt new file mode 100644 index 0000000000..8653c30b5c --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ContactDetailsScreens.kt @@ -0,0 +1,71 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatFeature +import chat.simplex.common.model.FeatureAllowed +import chat.simplex.common.model.FullChatPreferences +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui + +class UserPrefsScreen(private val s: UiScope) { + init { + s.poll(what = "the `Your preferences` screen") { s.exists(Ui.UserPrefs.Timed) && s.exists(Ui.UserPrefs.Allow(ChatFeature.Calls)) } + s.waitStable(Ui.UserPrefs.Allow(ChatFeature.FullDelete)) + } + + fun allow(f: ChatFeature, option: FeatureAllowed) { + s.click(Ui.UserPrefs.Allow(f)) + s.click(Ui.UserPrefs.Allow(f).option(option)) + } + + fun allowTexts(f: ChatFeature): List = s.textsOf(Ui.UserPrefs.Allow(f)) + + fun toggleTimed() = s.click(Ui.UserPrefs.Timed) + + fun reset() = s.click(Ui.UserPrefs.Reset) + + fun save() = s.click(Ui.UserPrefs.Save) + + fun backExpectingAlert() { + s.goBack() + s.poll(what = "the `Save preferences?` alert") { s.seesText("Save preferences?") } + } + + fun backClosing() { + s.goBack() + s.poll(what = "your preferences to close without an alert") { !s.exists(Ui.UserPrefs.Save) && !s.seesText("Save preferences?") } + } +} + +fun UiScope.userPrefs(app: App): UserPrefsScreen { + app.userPicker() + click(described("Chat preferences")) + return UserPrefsScreen(this) +} + +fun savedUserPrefs(): FullChatPreferences = chatModel.currentUser.value!!.fullPreferences + +fun UiScope.keepIncognitoOneTimeLink(app: App) { + val before = pendingConnectionIds() + app.newChat().createOneTimeLink() + click(Ui.Link.ShareProfile) + click(Ui.Link.ProfileIncognito) + poll(what = "the 1-time link switched to incognito") { seesText(NEW_RANDOM_PROFILE) && !exists(Ui.Link.ProfileIncognito) } + goBack() + closeNewChatSheet() + poll(what = "the incognito link's pending connection kept") { pendingConnectionIds().size == before.size + 1 } +} + +fun UiScope.archiveContact(app: App, name: String) { + app.chatList.rowMenu(name, "delete") + click(Ui.Alert.Option(Ui.AlertSource.Shared, "onlyConversation")) + poll(what = "`$name` archived and hidden from the chat list") { contactOf(name).chatDeleted && !app.chatList.shown(name) } +} + +fun UiScope.openArchivedContact(app: App, name: String): ChatScreen { + val id = app.chatList.chatId(name) + app.newChat() + click(described("Archived contacts")) + click(Ui.NewChat.Contact(id)) + poll(what = "`$name`'s archived chat open") { chatModel.chatId.value == id } + return ChatScreen(this) +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ContactInfoScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ContactInfoScreens.kt new file mode 100644 index 0000000000..b821a272ad --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ContactInfoScreens.kt @@ -0,0 +1,54 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.ServerAddress +import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import kotlinx.coroutines.runBlocking + +fun setContactAliasViaApi(name: String, alias: String) { + val contactId = contactOf(name).contactId + runBlocking { chatController.apiSetContactAlias(null, contactId, alias) } ?: error("apiSetContactAlias failed") +} + +fun markContactVerifiedViaApi(name: String) { + val contactId = contactOf(name).contactId + val code = runBlocking { chatController.apiGetContactCode(null, contactId) }?.second ?: error("apiGetContactCode failed") + val (verified, _) = runBlocking { chatController.apiVerifyContact(null, contactId, code) } ?: error("apiVerifyContact failed") + check(verified) { "apiVerifyContact did not verify $name with its own code" } +} + +fun localSmpServer(): ServerAddress { + val groups = runBlocking { chatController.getUserServers(null) } ?: error("getUserServers failed") + val server = groups.flatMap { it.smpServers }.first { it.enabled && !it.deleted && !it.preset }.server + return parseServerAddress(server) ?: error("cannot parse the local SMP server `$server`") +} + +fun isServer(text: String?, expected: ServerAddress): Boolean = + text != null && parseServerAddress(text)?.let { it.hostnames == expected.hostnames && it.port == expected.port && it.keyHash == expected.keyHash } == true + +fun UiScope.copiedServer(row: UiKey): String? { + setClipboardText(null) + click(row) + var copied: String? = null + poll(what = "the server of ${row.tag} on the clipboard") { copied = clipboardText()?.takeIf { it.isNotBlank() }; copied != null } + return copied +} + +fun UiScope.joinOneTimeLinkInSearch(link: String) { + enterLinkInSearch(link) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Connect via one-time link?` alert with `Use current profile`") { + seesText("Connect via one-time link?") && exists(planOption("useCurrentProfile")) + } + click(planOption("useCurrentProfile")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Connection request sent!`") { seesText("Connection request sent!") && exists(planConfirm) } + click(planConfirm) + poll(what = "the alert closed with a pending connection in the list") { + !seesText("Connection request sent!") && chatModel.chats.value.any { it.chatInfo is ChatInfo.ContactConnection } + } +} + +fun UiScope.infoNameText(): String? = if (exists(Ui.Info.Name)) textOf(Ui.Info.Name) else null diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ContactsScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ContactsScreens.kt new file mode 100644 index 0000000000..9658a83527 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ContactsScreens.kt @@ -0,0 +1,117 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.AddressSettings +import chat.simplex.common.model.ChatFeature +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.Contact +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.ModalManager + +fun contactOf(name: String): Contact = + (chatModel.chats.value.first { it.chatInfo.chatViewName == name }.chatInfo as ChatInfo.Direct).contact + +fun savedAddressSettings(): AddressSettings? = chatModel.userAddress.value?.addressSettings + +fun UiScope.shareAddress(): String { + click(Ui.Address.Share) + var link: String? = null + poll(what = "the address on the clipboard") { link = clipboardText()?.takeIf { it.isNotBlank() }; link != null } + return link!! +} + +class AddressSettingsScreen(private val s: UiScope) { + init { + s.poll(what = "the `Address settings` screen") { s.exists(Ui.Address.AutoAccept) && s.exists(Ui.Address.WelcomeMessage) } + s.waitStable(Ui.Address.AutoAccept) + } + + fun toggleAutoAccept() = s.click(Ui.Address.AutoAccept) + + fun autoAcceptEnabled(): Boolean = s.isEnabled(Ui.Address.AutoAccept) + + fun welcome(text: String) { + s.typeInto(Ui.Address.WelcomeMessage, text) + s.hideKeyboard() + } + + fun replaceWelcome(text: String) { + s.replaceText(Ui.Address.WelcomeMessage, text) + s.hideKeyboard() + } + + fun save() = s.click(Ui.Address.SaveSettings) + + fun close() { + s.escape() + s.poll(what = "address settings to close to the address screen") { !s.exists(Ui.Address.AutoAccept) && s.exists(Ui.Address.Business) } + s.waitStable(Ui.Address.Business) + } +} + +fun UiScope.addressSettings(): AddressSettingsScreen { + click(Ui.Address.Settings) + return AddressSettingsScreen(this) +} + +fun CliPeer.addressPlanOk(link: String): Boolean { + this sends "/_connect plan 1 $link" + val line = nextLine(CONNECT_TIMEOUT_MS) + if ("error: connection authorization failed" in line) return false + check(line == "contact address: ok to connect") { "$name: unexpected plan for the address: `$line`; next: ${pending()}" } + this sends "/u" + eventually("user profile: $name") + this expects "use /p [] to change it" + return true +} + +fun CliPeer.awaitAddressGone(link: String) { + val deadline = System.currentTimeMillis() + CONNECT_TIMEOUT_MS + while (addressPlanOk(link)) { + check(System.currentTimeMillis() < deadline) { "$name: the address still accepts connections ${CONNECT_TIMEOUT_MS / 1000} s after it was deleted" } + } +} + +class ContactPrefsScreen(private val s: UiScope) { + init { + s.poll(what = "the `Contact preferences` screen") { s.exists(Ui.ContactPrefs.Timed) && s.exists(Ui.ContactPrefs.Allow(ChatFeature.FullDelete)) } + s.waitStable(Ui.ContactPrefs.Allow(ChatFeature.FullDelete)) + } + + fun allow(f: ChatFeature, option: String) { + s.click(Ui.ContactPrefs.Allow(f)) + s.click(Ui.ContactPrefs.Allow(f).option(option)) + } + + fun allowTexts(f: ChatFeature): List = s.textsOf(Ui.ContactPrefs.Allow(f)) + + fun toggleTimed() = s.click(Ui.ContactPrefs.Timed) + + fun ttlShown(): Boolean = s.exists(Ui.ContactPrefs.Ttl) + + fun ttlTexts(): List = s.textsOf(Ui.ContactPrefs.Ttl) + + fun reset() = s.click(Ui.ContactPrefs.Reset) + + fun save() = s.click(Ui.ContactPrefs.Save) + + fun backExpectingAlert() { + s.goBack() + s.poll(what = "the `Save preferences?` alert") { s.seesText("Save preferences?") } + } + + fun backClosing() { + s.goBack() + s.poll(what = "contact preferences to close without an alert") { !s.exists(Ui.ContactPrefs.Save) && !s.seesText("Save preferences?") } + } +} + +fun UiScope.contactPrefs(): ContactPrefsScreen { + click(Ui.Info.Preferences) + return ContactPrefsScreen(this) +} + +fun UiScope.closeContactInfo() { + escape() + poll(what = "contact info to close") { !ModalManager.end.hasModalsOpen() } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/DatabaseScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/DatabaseScreens.kt new file mode 100644 index 0000000000..3b4000e3ee --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/DatabaseScreens.kt @@ -0,0 +1,107 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.CC +import chat.simplex.common.model.CR +import chat.simplex.common.model.ChatItemTTL +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.ui.UiKey +import chat.simplex.common.views.helpers.AlertManager +import kotlinx.coroutines.runBlocking + +const val DB_WORK_TIMEOUT_MS = 120_000L + +fun UiScope.typeSecret(key: UiKey, text: String) { + val before = editableText(key).orEmpty().length + enterText(key, text) + poll(what = "field ${key.tag} to hold ${before + text.length} characters") { editableText(key)?.length == before + text.length } +} + +fun chatRunningInCore(): Boolean = runBlocking { chatController.sendCmd(null, CC.CheckChatRunning()) }.result is CR.ChatRunning + +fun UiScope.chatData(app: App): ChatDataScreen { + app.settings() + click(Ui.Settings.Database) + waitFor(Ui.Database.Management) + return ChatDataScreen(this) +} + +class ChatDataScreen(private val s: UiScope) { + fun ttlText(): String? = s.textOf(Ui.Database.Ttl) + + fun selectTtl(ttl: ChatItemTTL) { + s.poll(what = "the previous message deletion change to finish before opening the picker") { UiIdle.inFlight().isEmpty() } + s.click(Ui.Database.Ttl) + s.click(Ui.Database.Ttl.option(ttl)) + } + + fun management(): DatabaseManagementScreen { + s.click(Ui.Database.Management) + s.waitFor(Ui.Database.Export) + return DatabaseManagementScreen(s) + } + + fun close() { + s.escape() + s.poll(what = "Chat data to close") { !s.exists(Ui.Database.Management) && s.exists(Ui.Settings.Database) } + s.escape() + s.poll(what = "Your settings to close") { !s.exists(Ui.Settings.Database) && s.exists(Ui.ChatList.Search) } + } +} + +class DatabaseManagementScreen(private val s: UiScope) { + fun passphrase(): PassphraseScreen { + s.click(Ui.Database.Passphrase) + s.waitFor(Ui.DbPassphrase.New) + return PassphraseScreen(s) + } + + fun close() { + s.escape() + s.poll(what = "Database passphrase & export to close") { !s.exists(Ui.Database.Export) && s.exists(Ui.Database.Management) } + ChatDataScreen(s).close() + } +} + +class PassphraseScreen(private val s: UiScope) { + fun setFirst(passphrase: String) { + s.typeSecret(Ui.DbPassphrase.New, passphrase) + s.typeSecret(Ui.DbPassphrase.Confirm, passphrase) + s.click(Ui.DbPassphrase.Update) + s.waitFor(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + s.click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + s.poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the Database encrypted! alert") { s.seesText("Database encrypted!") } + s.click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + s.poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the chat running again and the Current passphrase field") { + !AlertManager.shared.hasAlertsShown() && chatModel.chatRunning.value == true && s.exists(Ui.DbPassphrase.Current) + } + } + + fun change(current: String, new: String) { + s.typeSecret(Ui.DbPassphrase.Current, current) + s.typeSecret(Ui.DbPassphrase.New, new) + s.typeSecret(Ui.DbPassphrase.Confirm, new) + s.click(Ui.DbPassphrase.Update) + s.poll(what = "the Change database passphrase? alert with Update") { + s.seesText("Change database passphrase?") && "Update" in s.textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + s.click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + + fun reopen() { + close() + s.click(Ui.Database.Passphrase) + s.waitFor(Ui.DbPassphrase.New) + for (key in listOf(Ui.DbPassphrase.Current, Ui.DbPassphrase.New, Ui.DbPassphrase.Confirm)) { + check(s.editableText(key).isNullOrEmpty()) { "${key.tag} still holds ${s.editableText(key)?.length} characters after reopening" } + } + } + + fun close() { + s.hideKeyboard() + s.escape() + s.poll(what = "Database passphrase to close") { !s.exists(Ui.DbPassphrase.New) && s.exists(Ui.Database.Export) } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/DraftScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/DraftScreens.kt new file mode 100644 index 0000000000..9b04ebfa3a --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/DraftScreens.kt @@ -0,0 +1,14 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui + +fun UiScope.visitNotesAndBack(app: App, name: String): ChatScreen { + backToChatList() + app.chatList.open("Private notes") + poll(what = "the notes folder with an empty field and no context bar") { + chatModel.chatId.value == app.chatList.chatId("Private notes") && editableText(Ui.Chat.Message).isNullOrEmpty() && !seesDescription("Context icon") + } + backToChatList() + return app.chatList.open(name) +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ForwardScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ForwardScreens.kt new file mode 100644 index 0000000000..d50c2ff55f --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ForwardScreens.kt @@ -0,0 +1,25 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.GroupProfile +import chat.simplex.common.platform.chatController +import kotlinx.coroutines.runBlocking +import java.io.File + +fun CliPeer.sendsFileTo(contact: String, path: String, fileId: Int, name: String) { + this sends "/f @$contact $path" + this expects "use /fc $fileId to cancel sending" + eventually("completed uploading file $fileId ($name) for $contact", CONNECT_TIMEOUT_MS) +} + +fun CliPeer.receivesFile(fileId: Int, name: String, from: String) { + val dir = File(createFile(".fr-probe", "probe")).parent + this sends "/fr $fileId $dir/" + eventually("completed receiving file $fileId ($name) from $from", CONNECT_TIMEOUT_MS) +} + +fun newGroupsViaApi(vararg names: String) = names.forEach { + runBlocking { chatController.apiNewGroup(null, incognito = false, GroupProfile(it, "", null)) } ?: error("apiNewGroup($it) failed") +} + +fun UiScope.awaitRowText(app: App, name: String, text: String) = + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`$text` on $name's row") { text in app.chatList.rowTexts(name) } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupExtraScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupExtraScreens.kt new file mode 100644 index 0000000000..364b83715d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupExtraScreens.kt @@ -0,0 +1,47 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.ui.Ui + +fun UiScope.memberConnectionLoaded() { + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the member's servers loaded in member info") { exists(Ui.Info.ReceivingVia) } + waitStable(Ui.Info.ReceivingVia) +} + +fun UiScope.shownSecurityCode(): String { + click(Ui.Info.VerifyCode) + waitFor(Ui.VerifyCode.Code) + return textOf(Ui.VerifyCode.Code)!!.filterNot(Char::isWhitespace) +} + +fun UiScope.closeMemberInfo(name: String) { + val row = Ui.GroupInfo.Member(groupMemberId(name)) + escape() + poll(what = "$name's member info to close to group info") { !exists(Ui.MemberInfo.Role) && !exists(Ui.Info.VerifyCode) && exists(row) } +} + +fun UiScope.alertSaying(title: String, text: String) { + poll(what = "the `$title` alert saying `$text`") { seesText(title) && seesText(text) && exists(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the `$title` alert to close") { !seesText(text) } +} + +fun CliPeer.createShortAndFullGroupLink(group: String): Pair { + createGroup(group) + this sends "/create link #$group" + this expects "Group link is created!" + this expects "" + val short = nextLine() + val full = eventuallyMatching(Regex("The group link for old clients: (\\S+)")).groupValues[1] + return short to full +} + +fun CliPeer.joinsViaLink(link: String, group: String) { + this sends "/c $link" + this expects "connection request sent!" + eventually("#$group: you joined the group", CONNECT_TIMEOUT_MS) +} + +fun UiScope.memberConnected(info: GroupInfoScreen, name: String) = + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "$name listed as connected in the group's members") { + info.memberTexts(name).let { "connected" in it || "complete" in it } + } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupLifecycleScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupLifecycleScreens.kt new file mode 100644 index 0000000000..cc64057295 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupLifecycleScreens.kt @@ -0,0 +1,146 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import chat.simplex.common.views.helpers.ModalManager + +class InviteMembersScreen(private val s: UiScope) { + init { + s.poll(what = "the `Invite members` sheet with `New member role`") { s.exists(Ui.InviteMembers.Role) } + s.waitStable(Ui.InviteMembers.Role) + } + + fun listed(name: String): Boolean = s.exists(Ui.InviteMembers.Contact(contactId(name))) + + fun toggle(name: String) = s.click(Ui.InviteMembers.Contact(contactId(name))) + + fun roleTexts(): List = s.textsOf(Ui.InviteMembers.Role) + + fun role(r: GroupMemberRole) { + s.click(Ui.InviteMembers.Role) + s.click(Ui.InviteMembers.Role.option(r)) + } + + fun invite() { + s.click(Ui.InviteMembers.Invite) + s.poll(what = "the invite sheet to close") { !s.exists(Ui.InviteMembers.Role) } + } + + private fun contactId(name: String): Long = + chatModel.chats.value.firstNotNullOf { (it.chatInfo as? ChatInfo.Direct)?.contact?.takeIf { c -> c.displayName == name }?.contactId } +} + +class GroupProfileScreen(private val s: UiScope) { + init { + s.waitFor(Ui.GroupProfile.Name) + s.waitStable(Ui.GroupProfile.Name) + } + + fun name(): String? = s.editableText(Ui.GroupProfile.Name) + + fun shortDescr(): String? = s.editableText(Ui.GroupProfile.ShortDescr) + + fun rename(name: String) = s.replaceText(Ui.GroupProfile.Name, name) + + fun describe(text: String) = s.typeInto(Ui.GroupProfile.ShortDescr, text) + + fun save() { + s.click(Ui.GroupProfile.Save) + s.poll(what = "the group profile editor to close after `Save group profile`") { !s.exists(Ui.GroupProfile.Name) } + } +} + +class WelcomeMessageScreen(private val s: UiScope) { + init { + s.poll(what = "the `Welcome message` editor or read-only text") { s.exists(Ui.Welcome.Text) || s.exists(Ui.Welcome.Preview) } + } + + fun text(): String = s.editableText(Ui.Welcome.Text).orEmpty() + + fun type(text: String) = s.typeInto(Ui.Welcome.Text, text) + + fun replace(text: String) = s.replaceText(Ui.Welcome.Text, text) + + fun save() = s.click(Ui.Welcome.Save) + + fun close() { + s.goBack() + s.poll(what = "the welcome message screen to close without an alert") { + !s.exists(Ui.Welcome.Text) && !s.exists(Ui.Welcome.Preview) && !s.seesText("Save welcome message?") + } + } +} + +fun UiScope.groupInfoRow(key: UiKey) { + scrollTo(key) + click(key) +} + +fun UiScope.inviteMembers(): InviteMembersScreen { + groupInfoRow(Ui.GroupInfo.AddMembers) + return InviteMembersScreen(this) +} + +fun UiScope.editGroupProfile(): GroupProfileScreen { + groupInfoRow(Ui.GroupInfo.EditProfile) + return GroupProfileScreen(this) +} + +fun UiScope.welcomeMessage(): WelcomeMessageScreen { + groupInfoRow(Ui.GroupInfo.WelcomeMessage) + return WelcomeMessageScreen(this) +} + +fun UiScope.welcomeRowText(): List { + scrollTo(Ui.GroupInfo.WelcomeMessage) + return textsOf(Ui.GroupInfo.WelcomeMessage) +} + +fun UiScope.leaveGroupFromInfo(group: String) { + groupInfoRow(Ui.GroupInfo.Leave) + poll(what = "the `Leave group?` alert for `$group` with `Leave`") { + seesText("Leave group?") && seesText("$group\n\nYou will stop receiving messages from this group. Chat history will be preserved.") && + "Leave" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "group info to close after leaving `$group`") { !ModalManager.end.hasModalsOpen() } +} + +fun UiScope.deleteGroupFromInfo(group: String, warning: String) { + groupInfoRow(Ui.GroupInfo.Delete) + poll(what = "the `Delete group?` alert for `$group` saying `$warning`, with `Delete`") { + seesText("Delete group?") && seesText("$group\n\n$warning") && "Delete" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) +} + +fun UiScope.clearChatFromInfo(group: String) { + groupInfoRow(Ui.Info.ClearChat) + poll(what = "the `Clear chat?` alert for `$group` with `Clear`") { + seesText("Clear chat?") && seesText("$group\n\nAll messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you.") && + "Clear" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) +} + +fun UiScope.offersOnlyDelete(): Boolean { + scrollTo(Ui.GroupInfo.Delete) + return exists(Ui.GroupInfo.Delete) && !exists(Ui.GroupInfo.Leave) +} + +fun UiScope.offersOnlyLeave(): Boolean { + scrollTo(Ui.GroupInfo.Leave) + return exists(Ui.GroupInfo.Leave) && !exists(Ui.GroupInfo.Delete) +} + +fun UiScope.hostGroupWithWelcome(host: CliPeer, group: String, welcome: String, member: String) { + host.createGroup(group) + host sends "/set welcome #$group $welcome" + host expects "welcome message changed to:" + host expects welcome + host sends "/a $group $member" + host.eventually("invitation to join the group #$group sent to $member") +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupLinkScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupLinkScreens.kt new file mode 100644 index 0000000000..85e1a7bbf2 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupLinkScreens.kt @@ -0,0 +1,38 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.platform.chatController +import chat.simplex.common.ui.Ui +import kotlinx.coroutines.runBlocking + +fun UiScope.openNewGroupLink() { + click(Ui.GroupInfo.CreateLink) + waitFor(Ui.GroupLink.Share, CONNECT_TIMEOUT_MS) + waitStable(Ui.GroupLink.Share) +} + +fun UiScope.sharedGroupLink(previous: String? = null): String { + if (previous == null) setClipboardText(null) + click(Ui.GroupLink.Share) + var link: String? = null + poll(what = "the group link shared") { link = clipboardText()?.takeIf { it.isNotBlank() && it != previous }; link != null } + return link!! +} + +fun UiScope.pickGroupLinkRole(role: GroupMemberRole) { + click(Ui.GroupLink.Role) + click(Ui.GroupLink.Role.option(role)) +} + +fun coreGroupLinkRole(groupId: Long): GroupMemberRole? = + runBlocking { chatController.apiGetGroupLink(null, groupId) }?.acceptMemberRole + +fun coreMemberContactId(groupId: Long, name: String): Long? = + runBlocking { chatController.apiListMembers(null, groupId) }.firstOrNull { it.memberProfile.displayName == name }?.memberContactId + +fun UiScope.closeGroupLink() { + escape() + poll(what = "the group link screen to close to group info") { + !exists(Ui.GroupLink.Share) && !seesText("Create link") && (exists(Ui.GroupInfo.CreateLink) || exists(Ui.GroupInfo.GroupLink)) + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupMembersScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupMembersScreens.kt new file mode 100644 index 0000000000..97f8c210df --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupMembersScreens.kt @@ -0,0 +1,90 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.GroupFeature +import chat.simplex.common.model.GroupFeatureEnabled +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui + +fun UiScope.groupMemberId(name: String): Long { + var id: Long? = null + poll(what = "member `$name` in the model") { + id = chatModel.groupMembers.value.firstOrNull { it.memberProfile.displayName == name }?.groupMemberId + id != null + } + return id!! +} + +fun UiScope.membersTitleShown(count: Int): Boolean = seesText("$count members") + +fun UiScope.scrollToMembersEnd() = scrollTo(Ui.GroupInfo.Delete) + +fun UiScope.openMemberMenu(name: String): Long { + val id = groupMemberId(name) + scrollTo(Ui.GroupInfo.Member(id)) + openContextMenu(Ui.GroupInfo.Member(id)) + return id +} + +fun UiScope.selectMembers(first: String, vararg more: String) { + val id = openMemberMenu(first) + click(Ui.GroupInfo.MemberMenu(id, "select")) + poll(what = "`Selected 1` after `Select` on $first") { ChatScreen(this).selectionTitle() == "Selected 1" } + more.forEachIndexed { i, name -> + click(Ui.GroupInfo.Member(groupMemberId(name))) + poll(what = "`Selected ${i + 2}` after tapping $name") { ChatScreen(this).selectionTitle() == "Selected ${i + 2}" } + } +} + +fun UiScope.selectionEnded() = poll(what = "member selection to end") { ChatScreen(this).selectionTitle() == null } + +fun UiScope.confirmAlert(title: String, text: String, action: String) { + poll(what = "the `$title` alert saying `$text` with `$action`") { + seesText(title) && seesText(text) && action in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) +} + +fun UiScope.ownGroupOfThree(app: App, a: CliPeer, b: CliPeer, c: CliPeer, group: String = "team"): ChatScreen { + connect(a) + connect(b) + connect(c) + val chat = ownGroup(app, group, a, b, c) + c.eventuallyAll(lines = listOf("#$group: member ${a.name} is connected", "#$group: member ${b.name} is connected"), timeoutMs = CONNECT_TIMEOUT_MS) + quiesce() + return chat +} + +fun UiScope.linkGroupWithDirectMessages(app: App, group: String, vararg joiners: CliPeer): GroupInfoScreen { + val info = emptyGroup(app, group).groupInfo() + val prefs = info.preferences() + prefs.toggle(GroupFeature.DirectMessages) + prefs.save() + poll(what = "direct messages allowed in #$group") { groupInfoOf(group).fullGroupPreferences.directMessages.enable == GroupFeatureEnabled.ON } + prefs.backClosing() + val link = info.createLink() + val joined = mutableListOf() + for (j in joiners) { + j sends "/c $link" + j expects "connection request sent!" + j.eventuallyAll(lines = listOf("#$group: you joined the group") + joined.map { "#$group: member ${it.name} is connected" }, timeoutMs = CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "${j.name} listed as connected in #$group") { "connected" in info.memberTexts(j.name) } + joined += j + } + quiesce() + return info +} + +fun UiScope.eventIds(what: String, count: Int, match: (ChatItem) -> Boolean): List { + var ids = emptyList() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = what) { + ids = chatModel.chatsContext.chatItems.value.filter(match).map { it.id } + ids.size == count + } + return ids +} + +fun UiScope.itemMenu(id: Long, action: String) { + openContextMenu(Ui.Chat.Item(id)) + click(Ui.Chat.ItemMenu(id, action)) +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupPrefsScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupPrefsScreens.kt new file mode 100644 index 0000000000..b9eb63f410 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/GroupPrefsScreens.kt @@ -0,0 +1,185 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.GroupFeature +import chat.simplex.common.model.GroupInfo +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.model.MemberCriteria +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.helpers.ModalViewId + +fun groupInfoOf(name: String): GroupInfo = + (chatModel.chats.value.first { it.chatInfo.chatViewName == name }.chatInfo as ChatInfo.Group).groupInfo + +class GroupPreferencesScreen(private val s: UiScope) { + init { + s.poll(what = "the `Group preferences` screen") { s.seesText("Group preferences") && s.exists(Ui.GroupPrefs.MemberAdmission) } + s.waitStable(Ui.GroupPrefs.MemberAdmission) + } + + fun toggle(f: GroupFeature) = s.click(Ui.GroupPrefs.Toggle(f)) + + fun role(f: GroupFeature, r: GroupMemberRole?) { + s.click(Ui.GroupPrefs.Role(f)) + s.click(Ui.GroupPrefs.Role(f).option(r ?: "null")) + } + + fun ttl(seconds: Int) { + s.click(Ui.GroupPrefs.Ttl) + s.click(Ui.GroupPrefs.Ttl.option(seconds)) + } + + fun reset() = s.click(Ui.GroupPrefs.Reset) + + fun save() = s.click(Ui.GroupPrefs.Save) + + fun admission(): MemberAdmissionScreen { + s.click(Ui.GroupPrefs.MemberAdmission) + return MemberAdmissionScreen(s) + } + + fun backExpectingAlert(title: String) { + s.goBack() + s.poll(what = "the `$title` alert") { s.seesText(title) } + } + + fun backClosing() { + s.goBack() + s.poll(what = "group preferences to close without an alert") { !s.exists(Ui.GroupPrefs.MemberAdmission) && !s.seesText("Save preferences?") } + } +} + +class MemberAdmissionScreen(private val s: UiScope) { + init { + s.poll(what = "the `Member admission` screen") { s.seesText("Member admission") && s.seesText("Review members before admitting (\"knocking\").") } + } + + fun review(): List = s.textsOf(Ui.MemberAdmission.Review) + + fun select(c: MemberCriteria?) { + s.waitStable(Ui.MemberAdmission.Review) + s.click(Ui.MemberAdmission.Review) + s.click(Ui.MemberAdmission.Review.option(c ?: "null")) + } + + fun save() = s.click(Ui.MemberAdmission.Save) +} + +fun UiScope.enableReviewAll(info: GroupInfoScreen, group: String) { + val adm = info.preferences().admission() + poll(what = "`Review members` off before enabling review") { "off" in adm.review() } + adm.select(MemberCriteria.All) + adm.save() + poll(what = "review of all members saved for #$group") { groupInfoOf(group).groupProfile.memberAdmission?.review == MemberCriteria.All } + goBack() + poll(what = "member admission to close to group preferences") { !seesText("Review members before admitting (\"knocking\").") && exists(Ui.GroupPrefs.MemberAdmission) } + GroupPreferencesScreen(this).backClosing() +} + +class MemberSupportScreen(private val s: UiScope) { + init { + s.poll(what = "the `Chats with members` screen") { s.seesText("Chats with members") && !s.exists(Ui.GroupInfo.MemberSupport) } + } + + fun empty() = s.poll(what = "`No chats with members`") { s.seesText("No chats with members") } + + fun close() { + s.goBack() + s.poll(what = "`Chats with members` to close to group info") { s.exists(Ui.GroupInfo.MemberSupport) } + s.waitStable(Ui.GroupInfo.MemberSupport) + } + + fun rowTexts(name: String): List { + val id = chatModel.groupMembers.value.firstOrNull { it.memberProfile.displayName == name }?.groupMemberId ?: return emptyList() + return if (s.exists(Ui.MemberSupport.Row(id))) s.textsOf(Ui.MemberSupport.Row(id)) else emptyList() + } + + fun open(name: String): SupportChatScreen { + s.click(Ui.MemberSupport.Row(memberId(name))) + return SupportChatScreen(s).also { it.waitOpen() } + } + + fun menuAccept(name: String) { + val id = memberId(name) + s.openContextMenu(Ui.MemberSupport.Row(id)) + s.click(Ui.MemberSupport.RowMenu(id, "accept")) + } + + private fun memberId(name: String): Long { + var id: Long? = null + s.poll(what = "member `$name` in the model") { + id = chatModel.groupMembers.value.firstOrNull { it.memberProfile.displayName == name }?.groupMemberId + id != null + } + return id!! + } +} + +class SupportChatScreen(private val s: UiScope) { + fun waitOpen() = s.poll(what = "the support chat") { + chatModel.secondaryChatsContext.value != null && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT) + } + + fun items(): List = chatModel.secondaryChatsContext.value?.chatItems?.value.orEmpty() + + fun itemId(text: String): Long { + var id: Long? = null + s.poll(what = "support chat item `$text`") { + id = items().lastOrNull { it.content.text == text }?.id + id != null && s.exists(Ui.Chat.Item(id!!)) + } + return id!! + } + + fun send(text: String) { + s.typeInto(Ui.Chat.ScopeMessage, text) + s.poll(what = "the support chat send button to be enabled") { s.isEnabled(Ui.Chat.ScopeSend) } + s.pressEnter(Ui.Chat.ScopeMessage) + s.poll(what = "the support chat message field to clear after one Enter") { s.editableText(Ui.Chat.ScopeMessage).isNullOrEmpty() } + s.hideKeyboard() + } + + fun acceptVia(option: String) { + s.click(Ui.Chat.PendingAccept) + s.acceptAlert(option) + } + + fun reject() = s.click(Ui.Chat.PendingReject) + + fun close() { + s.goBack() + s.poll(what = "the support chat to close") { chatModel.secondaryChatsContext.value == null && !ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT) } + } +} + +fun UiScope.acceptAlert(option: String) { + poll(what = "the `Accept member` alert with `Accept as member` and `Accept as observer`") { + seesText("Accept member") && seesText("Member will join the group, accept member?") && seesText("Accept as member") && seesText("Accept as observer") + } + click(Ui.Alert.Option(Ui.AlertSource.Shared, option)) +} + +fun UiScope.pendingLinkJoin(app: App, joiner: CliPeer, group: String): ChatScreen { + val me = chatModel.currentUser.value!!.displayName + val chat = emptyGroup(app, group) + val info = chat.groupInfo() + info.chatsWithMembers().apply { empty() }.close() + enableReviewAll(info, group) + val link = info.createLink() + info.close() + joiner sends "/c $link" + joiner expects "connection request sent!" + joiner.eventuallyAll( + lines = listOf( + "#$group: $me accepted you to the group, pending review", + "#$group: joining the group...", + "#$group: you joined the group, connecting to group moderators for admission to group", + ), + timeoutMs = CONNECT_TIMEOUT_MS, + ) + quiesce() + return chat +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ItemInfoScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ItemInfoScreens.kt new file mode 100644 index 0000000000..6a72e5277b --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ItemInfoScreens.kt @@ -0,0 +1,15 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.views.helpers.ModalManager + +class ItemInfoScreen(private val s: UiScope) { + fun history(): List = s.texts().dropWhile { it != "History" }.drop(1) + + fun row(title: String): String? = s.texts().let { t -> t.indexOf(title).takeIf { it >= 0 }?.let { t.getOrNull(it + 1) } } + + fun close() { + s.hideKeyboard() + s.escape() + s.poll(what = "the item info to close") { !ModalManager.end.hasModalsOpen() } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/JoiningScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/JoiningScreens.kt new file mode 100644 index 0000000000..ce050b105b --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/JoiningScreens.kt @@ -0,0 +1,55 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui + +fun CliPeer.createWelcomeGroupLink(group: String, welcome: String): String { + createGroup(group) + this sends "/set welcome #$group $welcome" + this expects "welcome message changed to:" + this expects welcome + this sends "/create link #$group" + this expects "Group link is created!" + this expects "" + return nextLine() +} + +fun CliPeer.createFullGroupLink(group: String): String { + createGroup(group) + this sends "/create link #$group" + this expects "Group link is created!" + return eventuallyMatching(Regex("The group link for old clients: (\\S+)")).groupValues[1] +} + +fun groupChatIds(name: String): List = + chatModel.chats.value.filter { it.chatInfo is ChatInfo.Group && it.chatInfo.chatViewName == name }.map { it.id } + +fun UiScope.openPreparedGroupFromSearch(link: String, name: String): ChatScreen { + enterLinkInSearch(link) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the plan alert for group `$name` with `Open group`") { + exists(planConfirm) && "Open group" in textsOf(planConfirm) && seesText(name) + } + click(planConfirm) + poll(what = "`$name`'s prepared group open") { + chatModel.chats.value.firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.chatViewName == name && it.chatInfo.nextConnectPrepared } + ?.id?.let { it == chatModel.chatId.value } == true + } + return ChatScreen(this) +} + +fun UiScope.openChatById(id: String): ChatScreen { + click(Ui.ChatList.Row(id)) + poll(what = "chat $id to open") { chatModel.chatId.value == id } + return ChatScreen(this) +} + +fun UiScope.connectionRequestSentToGroup() { + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Connection request sent!` for a group link") { + seesText("Connection request sent!") && seesText("You will be connected to group when the group host's device is online, please wait or check later!") + } + click(planConfirm) + poll(what = "the alert to close and the search to clear") { + !seesText("Connection request sent!") && editableText(Ui.ChatList.Search).isNullOrEmpty() && isEnabled(Ui.ChatList.Search) + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/MessageMetaScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/MessageMetaScreens.kt new file mode 100644 index 0000000000..555c482df3 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/MessageMetaScreens.kt @@ -0,0 +1,54 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.ui.Ui +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant + +private const val PEER_RECHECK_MS = 1_000L +private val SENT_AT = Regex("sent at: (\\S+ \\S+)") +private val RECEIVED_AT = Regex("received at: .+") +private val DELETE_AT = Regex("to be deleted at: (\\S+ \\S+)") +private val SERVERS_SUMMARY = Regex("agent servers summary: .*") +private val CONN_DELETED = Regex("\"_connDeleted\":[1-9]") + +fun UiScope.openTimedMenu() { + rightClick(Ui.Chat.Send) + click(Ui.Chat.SendTimed) + poll(what = "the `Send disappearing message` menu with `30 seconds` and `Custom time`") { + seesText("Send disappearing message") && exists(Ui.Chat.SendTimed.option(30)) && exists(Ui.Chat.SendTimed.option("custom")) + } +} + +fun CliPeer.receivedTtlSeconds(contact: String, text: String): Long { + sends("/item info @$contact $text") + val sentAt = eventuallyMatching(SENT_AT).groupValues[1] + eventuallyMatching(RECEIVED_AT) + val deleteAt = eventuallyMatching(DELETE_AT).groupValues[1] + return (localTs(deleteAt) - localTs(sentAt)).inWholeSeconds +} + +fun UiScope.awaitGoneFromPeer(peer: CliPeer, contact: String, line: String) { + var askedAt = 0L + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`$line` gone from the end of ${peer.name}'s chat with $contact") { + if (System.currentTimeMillis() - askedAt < PEER_RECHECK_MS) return@poll false + askedAt = System.currentTimeMillis() + peer sends "/tail @$contact 1" + !peer.nextLine().endsWith(line) + } +} + +fun UiScope.awaitConnectionDeletedOnServer(peer: CliPeer) { + var askedAt = 0L + var deleted = false + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the server to confirm ${peer.name}'s deleted connection") { + if (System.currentTimeMillis() - askedAt >= PEER_RECHECK_MS) { + askedAt = System.currentTimeMillis() + peer sends "/get servers summary 1" + deleted = CONN_DELETED.containsMatchIn(peer.eventuallyMatching(SERVERS_SUMMARY).value) + } + deleted + } +} + +private fun localTs(s: String) = LocalDateTime.parse(s.replace(' ', 'T')).toInstant(TimeZone.UTC) diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/NetworkScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/NetworkScreens.kt new file mode 100644 index 0000000000..197426c311 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/NetworkScreens.kt @@ -0,0 +1,263 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.CC +import chat.simplex.common.model.CR +import chat.simplex.common.model.NetCfg +import chat.simplex.common.model.NetworkProxy +import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress +import chat.simplex.common.model.ServerOperator +import chat.simplex.common.model.UserOperatorServers +import chat.simplex.common.model.UserServer +import chat.simplex.common.model.json +import chat.simplex.common.platform.chatController +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import chat.simplex.common.views.helpers.AlertManager +import kotlinx.coroutines.runBlocking + +const val UNREACHABLE_HOST = "127.0.0.2" + +fun coreNetworkConfig(): NetCfg { + val r = runBlocking { chatController.sendCmd(null, CC.APIGetNetworkConfig()) } + return (r.result as? CR.NetworkConfig)?.networkConfig ?: error("/network failed: ${r.responseType} ${r.details}") +} + +fun saveNetworkConfigViaApi(cfg: NetCfg) { + check(runBlocking { chatController.apiSetNetworkConfig(cfg) }) { "apiSetNetworkConfig failed" } + chatController.setNetCfg(cfg) +} + +fun coreOperators(): List = + runBlocking { chatController.getServerOperators(null) }?.serverOperators ?: error("getServerOperators failed") + +fun customServers(): UserOperatorServers = + (runBlocking { chatController.getUserServers(null) } ?: error("getUserServers failed")).single { it.operator == null } + +fun hostOf(server: String): String? = parseServerAddress(server)?.hostnames?.firstOrNull() + +fun unreachableSmpServer(): String = "smp://${localSmpServer().keyHash}@$UNREACHABLE_HOST:1" + +fun customSmpServer(host: String): UserServer? = customServers().smpServers.firstOrNull { hostOf(it.server) == host } + +fun addCustomSmpServerViaApi(server: String): String { + val groups = runBlocking { chatController.getUserServers(null) } ?: error("getUserServers failed") + val added = UserServer(remoteHostId = null, serverId = null, server = server, preset = false, enabled = true, deleted = false) + val updated = groups.map { g -> if (g.operator == null) g.copy(smpServers = g.smpServers + added) else g } + check(runBlocking { chatController.setUserServers(null, updated) }) { "setUserServers failed" } + return customSmpServer(hostOf(server)!!)?.server ?: error("the added server `$server` is not in the core") +} + +fun UiScope.networkAndServers(app: App): NetworkScreen { + app.settings() + return openNetworkFromSettings() +} + +fun UiScope.openNetworkFromSettings(): NetworkScreen { + click(Ui.Settings.Network) + return NetworkScreen(this) +} + +class NetworkScreen(private val s: UiScope) { + init { + s.poll(what = "`Network & servers` with the servers loaded") { s.exists(Ui.Network.YourServers) && s.exists(Ui.Network.SaveServers) && !s.exists(Ui.Settings.Network) } + } + + fun edited(key: UiKey): Boolean = EDITED in s.descriptionsOf(key) + + fun yourServers(): YourServersScreen { + s.click(Ui.Network.YourServers) + return YourServersScreen(s) + } + + fun operator(operatorId: Long): OperatorScreen { + s.click(Ui.Network.Operator(operatorId)) + return OperatorScreen(s) + } + + fun save(row: UiKey) { + s.click(Ui.Network.SaveServers) + s.poll(what = "`Save servers` to save and clear the `edited` mark of ${row.tag}") { !edited(row) && !AlertManager.shared.hasAlertsShown() } + } + + fun advanced(): AdvancedNetworkScreen { + s.click(Ui.Network.Advanced) + return AdvancedNetworkScreen(s) + } + + fun socks(): SocksScreen { + s.click(Ui.Network.SocksSettings) + return SocksScreen(s) + } + + fun enableSocks() { + val confirm = Ui.Alert.Confirm(Ui.AlertSource.Shared) + s.click(Ui.Network.UseSocks) + s.poll(what = "the `Use SOCKS proxy?` alert with `Confirm`") { s.seesText("Use SOCKS proxy?") && s.exists(confirm) && "Confirm" in s.textsOf(confirm) } + s.click(confirm) + s.poll(what = "the `Use SOCKS proxy?` alert to close") { !s.seesText("Use SOCKS proxy?") } + } + + fun close() { + s.goBack() + awaitClosed() + } + + fun awaitClosed() = s.poll(what = "`Network & servers` to close to the settings") { !s.exists(Ui.Network.SaveServers) && s.exists(Ui.Settings.Network) } + + private companion object { + const val EDITED = "edited" + } +} + +class YourServersScreen(private val s: UiScope) { + init { + s.poll(what = "`Your servers`") { s.exists(Ui.Servers.Add) && !s.exists(Ui.Network.SaveServers) } + } + + fun open(server: String): ServerScreen { + s.click(Ui.Servers.Server(server)) + return ServerScreen(s) + } + + fun add(): ServerScreen { + s.click(Ui.Servers.Add) + val manually = Ui.Alert.Option(Ui.AlertSource.Shared, "enterManually") + s.poll(what = "the `Add server` alert with `Enter server manually`") { s.exists(manually) && "Enter server manually" in s.textsOf(manually) } + s.click(manually) + s.poll(what = "`New server`") { s.seesText("New server") && s.exists(Ui.Servers.Address) && !s.exists(Ui.Servers.Add) } + return ServerScreen(s) + } + + fun testAll() { + s.click(Ui.Servers.TestAll) + s.poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the server tests to finish (`Add server` clickable again)") { s.exists(described(ADD_SERVER)) } + } + + fun back() { + s.goBack() + s.poll(what = "back on `Network & servers`") { !s.exists(Ui.Servers.Add) && s.exists(Ui.Network.SaveServers) } + } + + private companion object { + const val ADD_SERVER = "Add server" + } +} + +class ServerScreen(private val s: UiScope) { + init { + s.poll(what = "the server screen with `Use for new connections`") { s.exists(Ui.Servers.Use) && !s.exists(Ui.Servers.Add) } + } + + fun enterAddress(address: String) = s.typeInto(Ui.Servers.Address, address) + + fun toggleUse() = s.click(Ui.Servers.Use) + + fun test() = s.click(Ui.Servers.Test) + + fun delete() { + s.click(Ui.Servers.Delete) + awaitClosed() + } + + fun back() { + s.goBack() + awaitClosed() + } + + private fun awaitClosed() = s.poll(what = "back on `Your servers`") { !s.exists(Ui.Servers.Use) && s.exists(Ui.Servers.Add) } +} + +class OperatorScreen(private val s: UiScope) { + init { + s.poll(what = "the operator screen with `Use servers`") { s.exists(Ui.Operator.Use) && !s.exists(Ui.Network.SaveServers) } + } + + fun info() { + s.click(Ui.Operator.Info) + s.poll(what = "`Network operator`") { s.seesText("Network operator") && !s.exists(Ui.Operator.Use) } + } + + fun closeInfo() { + s.goBack() + s.poll(what = "back on the operator screen") { !s.seesText("Network operator") && s.exists(Ui.Operator.Use) } + } + + fun toggleUse() = s.click(Ui.Operator.Use) + + fun back() { + s.goBack() + s.poll(what = "back on `Network & servers`") { !s.exists(Ui.Operator.Use) && s.exists(Ui.Network.SaveServers) } + } +} + +class AdvancedNetworkScreen(private val s: UiScope) { + init { + s.poll(what = "`Advanced settings`") { s.exists(Ui.AdvancedNetwork.Save) && !s.exists(Ui.Network.SaveServers) } + } + + fun value(name: String): String? = s.textsOf(Ui.AdvancedNetwork.Setting(name)).firstOrNull()?.trim() + + fun select(name: String, value: Any) { + val row = Ui.AdvancedNetwork.Setting(name) + s.click(row) + s.click(row.option(value)) + s.poll(what = "the `$name` menu to close") { !s.exists(row.option(value)) } + } + + fun pick(row: UiKey, value: Any) { + s.click(row) + s.poll(what = "the ${row.tag} options") { s.exists(row.option(value)) && !s.exists(Ui.AdvancedNetwork.Save) } + s.click(row.option(value)) + s.goBack() + s.poll(what = "back on `Advanced settings` from the ${row.tag} options") { !s.exists(row.option(value)) && s.exists(Ui.AdvancedNetwork.Save) } + } + + fun saveAndReconnect() { + s.click(Ui.AdvancedNetwork.Save) + confirmUpdate(s) + } + + fun back() { + s.goBack() + awaitClosed() + } + + fun awaitClosed() = s.poll(what = "`Advanced settings` to close") { !s.exists(Ui.AdvancedNetwork.Save) && s.exists(Ui.Network.SaveServers) } +} + +class SocksScreen(private val s: UiScope) { + init { + s.poll(what = "`SOCKS proxy settings`") { s.exists(Ui.Socks.Save) && s.exists(Ui.Socks.OnionHosts) && !s.exists(Ui.Network.SaveServers) } + } + + fun onionHosts(): List = s.textsOf(Ui.Socks.OnionHosts) + + fun saveProxy(host: String, port: Int) { + s.replaceText(Ui.Socks.Host, host) + s.replaceText(Ui.Socks.Port, port.toString()) + s.hideKeyboard() + s.click(Ui.Socks.Save) + s.poll(what = "the proxy $host:$port stored") { s.storedSetting(PROXY_PREF)?.let { json.decodeFromString(NetworkProxy.serializer(), it) }?.let { it.host == host && it.port == port } == true } + } + + fun saveAndReconnect() { + s.click(Ui.Socks.Save) + confirmUpdate(s) + } + + fun back() { + s.goBack() + s.poll(what = "`SOCKS proxy settings` to close") { !s.exists(Ui.Socks.Save) && s.exists(Ui.Network.SaveServers) } + } + + private companion object { + const val PROXY_PREF = "NetworkProxyHostPort" + } +} + +private fun confirmUpdate(s: UiScope) { + val confirm = Ui.Alert.Confirm(Ui.AlertSource.Shared) + s.poll(what = "the `Update network settings?` alert with `Update`") { s.seesText("Update network settings?") && s.exists(confirm) && "Update" in s.textsOf(confirm) } + s.click(confirm) + s.poll(what = "the `Update network settings?` alert to close") { !s.seesText("Update network settings?") } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/OneTimeLinkScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/OneTimeLinkScreens.kt new file mode 100644 index 0000000000..cfdbefaf27 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/OneTimeLinkScreens.kt @@ -0,0 +1,49 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager + +const val NEW_RANDOM_PROFILE = "A new random profile will be shared." +private val INVITATION_PLAN = Regex("invitation link: ok to connect|error: connection authorization failed.*") + +fun pendingConnectionIds(): List = + chatModel.chats.value.filter { it.chatInfo is ChatInfo.ContactConnection }.map { it.id } + +fun UiScope.leaveInviteExpectingKeepAlert() { + goBack() + poll(what = "the `Keep unused invitation?` alert with `Delete` and `Keep`") { + seesText("Keep unused invitation?") && seesText("You can view invitation link again in connection details.") && + "Delete" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && "Keep" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } +} + +fun UiScope.awaitInviteClosed() = + poll(what = "the 1-time link screen to close, back on the New chat sheet or the chat list") { + !exists(Ui.Link.Text) && (exists(Ui.NewChat.CreateLink) || !chatModel.newChatSheetVisible.value) + } + +fun UiScope.closeNewChatSheet() { + awaitInviteClosed() + poll(what = "no alert over the New chat sheet") { !AlertManager.shared.hasAlertsShown() } + if (exists(Ui.NewChat.CreateLink)) goBack() + poll(what = "the New chat sheet to close") { !chatModel.newChatSheetVisible.value && !exists(Ui.NewChat.CreateLink) } +} + +fun UiScope.chooseLinkProfile(userId: Long, oldLink: String): String { + scrollTo(Ui.Link.Profile(userId)) + click(Ui.Link.Profile(userId)) + var link: String? = null + poll(what = "user $userId active, the profile list closed and a new link shown") { + link = editableText(Ui.Link.Text)?.takeIf { it.isNotBlank() } + chatModel.currentUser.value?.userId == userId && !exists(Ui.Link.Profile(userId)) && link != null && link != oldLink + } + return link!! +} + +fun UiScope.awaitInvitationGone(peer: CliPeer, link: String) = + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the deleted 1-time link to fail its plan on the server") { + peer sends "/_connect plan 1 $link" + peer.eventuallyMatching(INVITATION_PLAN).value.startsWith("error") + } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PaginationScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PaginationScreens.kt new file mode 100644 index 0000000000..d5ae1ab5b6 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PaginationScreens.kt @@ -0,0 +1,30 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatType +import chat.simplex.common.model.ComposedMessage +import chat.simplex.common.model.MsgContent +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import kotlinx.coroutines.runBlocking + +private const val API_BATCH = 100 + +fun sendTextsViaApi(contact: String, texts: List) = + texts.chunked(API_BATCH).forEach { batch -> sendViaApi(contact, batch.map { ComposedMessage(null, null, MsgContent.MCText(it), emptyMap()) }) } + +fun replyViaApi(contact: String, quotedItemId: Long, text: String) = + sendViaApi(contact, listOf(ComposedMessage(null, quotedItemId, MsgContent.MCText(text), emptyMap()))) + +private fun sendViaApi(contact: String, messages: List) { + val contactId = contactOf(contact).contactId + runBlocking { chatController.apiSendMessages(null, ChatType.Direct, contactId, null, composedMessages = messages) } ?: error("apiSendMessages failed") +} + +fun loadedText(text: String): Boolean = chatModel.chatsContext.chatItems.value.any { it.content.text == text } + +fun UiScope.shownText(text: String): Boolean = + chatModel.chatsContext.chatItems.value.lastOrNull { it.content.text == text }?.let { exists(Ui.Chat.Item(it.id)) } == true + +fun loadedNumbered(prefix: String): List = + Regex("${Regex.escape(prefix)}\\d+").let { r -> chatModel.chatsContext.chatItems.value.map { it.content.text }.filter { r.matches(it) } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PeerLines.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PeerLines.kt index 5ba2d88231..c2d41fa8b1 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PeerLines.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PeerLines.kt @@ -4,7 +4,14 @@ fun interface Waiter { fun waitUntil(timeoutMs: Long, what: String, cond: () -> Boolean) } -data class PeerFlags(val reactions: Boolean = false, val markRead: Boolean = false, val fullLinks: Boolean = false) +data class PeerFlags( + val reactions: Boolean = false, + val markRead: Boolean = false, + val fullLinks: Boolean = false, + val relay: Boolean = false, + val smpServer: String = "smp1", + val xftpServer: String = "xftp1" +) abstract class PeerLines(val name: String, protected val waiter: Waiter) { protected val lines = ArrayList() @@ -51,6 +58,12 @@ abstract class PeerLines(val name: String, protected val waiter: Waiter) { } } + fun eventuallyAll(lines: List, messages: List = emptyList(), timeoutMs: Long = LINE_TIMEOUT_MS) { + val wanted = lines.map { l -> { s: String -> s == l } } + messages.map { m -> { s: String -> stripTime(s) == m } } + await(timeoutMs, "$name: all of ${lines + messages}") { lastIndexOfAll(wanted) != null } + synchronized(this.lines) { lastIndexOfAll(wanted)?.takeIf { it >= 0 }?.let { dropThrough(it) } } + } + fun nextLine(timeoutMs: Long = LINE_TIMEOUT_MS): String { await(timeoutMs, "$name: next output line") { synchronized(lines) { lines.isNotEmpty() } } return synchronized(lines) { lines.removeAt(0) } @@ -94,6 +107,11 @@ abstract class PeerLines(val name: String, protected val waiter: Waiter) { protected fun stripTime(line: String): String = TIME_PREFIX.replaceFirst(line, "") + private fun lastIndexOfAll(wanted: List<(String) -> Boolean>): Int? = synchronized(lines) { + val used = HashSet() + wanted.maxOfOrNull { p -> lines.indices.firstOrNull { it !in used && p(lines[it]) }?.also { used += it } ?: return null } ?: -1 + } + private fun takeFirst(pred: (String) -> Boolean): String? = synchronized(lines) { val i = lines.indexOfFirst(pred) if (i >= 0) lines.removeAt(i) else null diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PrivacyScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PrivacyScreens.kt new file mode 100644 index 0000000000..a645ea2f2a --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PrivacyScreens.kt @@ -0,0 +1,131 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +fun UiScope.yourPrivacy(app: App): PrivacyScreen { + app.settings() + click(Ui.Settings.Privacy) + waitFor(Ui.Privacy.More) + return PrivacyScreen(this) +} + +class PrivacyScreen(private val s: UiScope) { + fun more(): PrivacyScreen { + s.click(Ui.Privacy.More) + s.waitFor(Ui.Privacy.ShowLastMessages) + return this + } + + fun lock(): LockScreen { + s.click(Ui.Privacy.Lock) + s.waitFor(Ui.Lock.Enable) + return LockScreen(s) + } + + fun closeMore() { + s.escape() + s.poll(what = "More privacy to close") { !s.exists(Ui.Privacy.ShowLastMessages) && s.exists(Ui.Privacy.More) } + } + + fun close() { + s.escape() + s.poll(what = "Your privacy to close") { !s.exists(Ui.Privacy.More) && s.exists(Ui.Settings.Privacy) } + s.escape() + s.poll(what = "Your settings to close") { !s.exists(Ui.Settings.Privacy) && s.exists(Ui.ChatList.Search) } + } +} + +class LockScreen(private val s: UiScope) { + val passcode = PasscodeScreen(s) + + fun enable(code: String) { + s.click(Ui.Lock.Enable) + passcode.await(NEW_PASSCODE) + passcode.enter(code) + passcode.submit() + passcode.await(CONFIRM_PASSCODE) + passcode.enter(code) + passcode.submit() + passcode.confirmAlert(PASSCODE_SET) + s.poll(what = "Lock after and Change passcode on the lock screen") { s.exists(Ui.Lock.Delay) && s.exists(Ui.Lock.ChangePasscode) } + } + + fun close() { + s.escape() + s.poll(what = "SimpleX Lock to close") { !s.exists(Ui.Lock.Enable) && s.exists(Ui.Privacy.Lock) } + PrivacyScreen(s).close() + } + + companion object { + const val NEW_PASSCODE = "New Passcode" + const val CONFIRM_PASSCODE = "Confirm passcode" + const val ENTER_PASSCODE = "Enter Passcode" + const val PASSCODE_SET = "Passcode set!" + } +} + +class PasscodeScreen(private val s: UiScope) { + fun await(title: String) = + s.poll(what = "the `$title` passcode screen") { s.seesText(title) && s.exists(Ui.Passcode.Submit) } + + fun enter(code: String) { + for (c in code) s.click(Ui.Passcode.Digit(c.digitToInt())) + } + + fun submitLabel(): String? = s.textsOf(Ui.Passcode.Submit).firstOrNull() + + fun submit() = s.click(Ui.Passcode.Submit) + + fun clear() = s.click(Ui.Passcode.Clear) + + fun backspace() = s.click(Ui.Passcode.Backspace) + + fun gone(): Boolean = !s.exists(Ui.Passcode.Submit) + + fun confirmAlert(title: String) { + s.poll(what = "the `$title` alert") { s.seesText(title) && AlertManager.shared.hasAlertsShown() } + s.click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + s.poll(what = "the `$title` alert to close") { !AlertManager.shared.hasAlertsShown() } + } + + fun authFailed() { + s.poll(what = "the passcode screen to close") { gone() } + s.poll(what = "the Authentication failed alert") { s.seesText(AUTH_FAILED) && s.seesText(NOT_VERIFIED) } + confirmAlert(AUTH_FAILED) + } + + private companion object { + const val AUTH_FAILED = "Authentication failed" + const val NOT_VERIFIED = "You could not be verified; please try again." + } +} + +fun PeerLines.groupId(group: String): Long { + sends("/i #$group") + val id = eventuallyMatching(Regex("group ID: (\\d+)")).groupValues[1].toLong() + eventuallyMatching(Regex("current members: \\d+")) + return id +} + +fun PeerLines.chatItems(chatRef: String, count: Int = 20): List { + sends("/_get chat $chatRef count=$count") + val json = eventuallyMatching(CHAT_JSON).value + return Json.parseToJsonElement(json).jsonObject.getValue("chatItems").jsonArray.map { it.jsonObject } +} + +fun PeerLines.item(chatRef: String, text: String): JsonObject? = + chatItems(chatRef).lastOrNull { it.getValue("meta").jsonObject["itemText"]?.jsonPrimitive?.content == text } + +fun PeerLines.itemStatus(chatRef: String, text: String): String? = + item(chatRef, text)?.let { it.getValue("meta").jsonObject["itemStatus"]?.jsonObject?.get("type")?.jsonPrimitive?.content } + +fun PeerLines.msgContent(chatRef: String, text: String): JsonObject? = + item(chatRef, text)?.get("content")?.jsonObject?.get("msgContent")?.jsonObject + +private val CHAT_JSON = Regex("\\{.*\"chatItems\":.*") diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ProfilesAddressScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ProfilesAddressScreens.kt new file mode 100644 index 0000000000..31502c1c92 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ProfilesAddressScreens.kt @@ -0,0 +1,74 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.ui.UiKey + +val PROFILES_SEARCH = Ui.Profiles.Bar.part("search") + +fun userHidden(userId: Long): Boolean = chatModel.users.firstOrNull { it.user.userId == userId }?.user?.hidden == true + +fun addressShared(): Boolean = chatModel.currentUser.value?.addressShared == true + +fun UiScope.labelled(key: UiKey, text: String): Boolean = exists(key) && text in textsOf(key) + +fun UiScope.awaitAppIdle(what: String) = poll(what = "app work started by $what to finish") { UiIdle.inFlight().isEmpty() } + +fun UiScope.typePassword(key: UiKey, text: String) { + val before = editableText(key).orEmpty().length + enterText(key, text) + poll(what = "field ${key.tag} to hold ${before + text.length} characters") { editableText(key)?.length == before + text.length } +} + +fun UiScope.closeTopScreen(what: String, closed: () -> Boolean) { + hideKeyboard() + escape() + poll(what = "$what to close", cond = closed) +} + +fun UiScope.closeProfilesScreen() { + hideKeyboard() + click(described("Back")) + poll(what = "the profiles screen to close") { !exists(PROFILES_SEARCH) } +} + +fun UiScope.dismissNewChatSheet() { + hideKeyboard() + goBack() + poll(what = "the New chat sheet to close") { !exists(Ui.NewChat.Search) } +} + +fun UiScope.pickerRowOpens(app: App, row: UiKey, what: String, shown: () -> Boolean) { + app.userPicker() + click(row) + poll(what = "$what, with the user picker closed") { shown() && !exists(Ui.UserPicker.Sheet) } +} + +fun UiScope.pickerRowInert(app: App, row: UiKey, what: String, opened: () -> Boolean) { + app.userPicker() + waitFor(row) + if (isEnabled(row)) click(row) + settle() + awaitAppIdle("tapping ${row.tag}") + check(exists(Ui.ChatList.Search) && !opened()) { "${row.tag} opened $what while chat is stopped" } + if (exists(Ui.UserPicker.Sheet)) { + escape() + poll(what = "the user picker to close") { !exists(Ui.UserPicker.Sheet) } + } +} + +fun UiScope.leaveAddressSettingsExpectingSaveAlert() { + hideKeyboard() + escape() + poll(what = "the `Save settings?` alert with `Save SimpleX address settings` and `Exit without saving`") { + seesText("Save settings?") && + labelled(Ui.Alert.Confirm(Ui.AlertSource.Shared), "Save SimpleX address settings") && + labelled(Ui.Alert.Dismiss(Ui.AlertSource.Shared), "Exit without saving") + } +} + +fun UiScope.awaitAddressScreenAfterSettings() { + poll(what = "address settings to close to the address screen") { !exists(Ui.Address.AutoAccept) && exists(Ui.Address.Business) } + waitStable(Ui.Address.Business) +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ProfilesScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ProfilesScreens.kt new file mode 100644 index 0000000000..19529e7e28 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ProfilesScreens.kt @@ -0,0 +1,63 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import kotlinx.coroutines.runBlocking + +private const val RESEND_MS = 2_000L + +fun userIdOf(name: String): Long = chatModel.users.first { it.user.displayName == name }.user.userId + +fun userShowsNtfs(userId: Long): Boolean? = chatModel.users.firstOrNull { it.user.userId == userId }?.user?.showNtfs + +fun hideUserViaApi(userId: Long, password: String) { + val user = chatModel.users.first { it.user.userId == userId }.user + chatModel.updateUser(runBlocking { chatController.apiHideUser(user, password) }) +} + +fun setFullNameViaApi(fullName: String) { + val profile = chatModel.currentUser.value!!.profile.toProfile().copy(fullName = fullName) + val (updated, _) = runBlocking { chatController.apiUpdateProfile(null, profile) } ?: error("apiUpdateProfile failed") + chatModel.updateCurrentUser(null, updated) +} + +fun UiScope.unreadBadge(key: UiKey): String? = + textsOf(key).firstOrNull { it.isNotEmpty() && it.all(Char::isDigit) } + +fun UiScope.openProfileMenu(userId: Long, action: String) { + openContextMenu(Ui.Profiles.User(userId)) + waitFor(Ui.Profiles.UserMenu(userId, "delete")) + check(exists(Ui.Profiles.UserMenu(userId, action))) { "the menu of user $userId has no `$action`" } +} + +fun UiScope.profileEditorSave() { + hideKeyboard() + poll(what = "`Save and notify contacts` to be enabled") { isEnabled(Ui.Profile.Save) } + click(Ui.Profile.Save) +} + +fun UiScope.leaveProfileEditorExpectingSaveAlert() { + hideKeyboard() + goBack() + poll(what = "the `Save preferences?` alert with `Save and notify contacts` and `Exit without saving`") { + seesText("Save preferences?") && + "Save and notify contacts" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && + "Exit without saving" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } +} + +fun UiScope.sendUntilAuthFailed(peer: CliPeer, contact: String) { + val failed = Regex("\\[$contact, contactId: \\d+, connId: \\d+] error: connection authorization failed.*") + var sentAt = 0L + var n = 0 + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "${peer.name}'s messages to $contact to fail with `connection authorization failed`") { + if (System.currentTimeMillis() - sentAt >= RESEND_MS) { + peer sends "@$contact are you there ${++n}" + sentAt = System.currentTimeMillis() + } + peer.pending().any { failed.matches(it) } + } + peer.eventuallyMatching(failed) +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/RemoteScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/RemoteScreens.kt new file mode 100644 index 0000000000..4a16ad69a5 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/RemoteScreens.kt @@ -0,0 +1,164 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import kotlinx.coroutines.runBlocking + +class RemoteCtrlOffer(val ctrlName: String, val sessionCode: String) + +fun CliPeer.setDeviceName(name: String) { + this sends "/set device name $name" + eventually("ok") +} + +fun CliPeer.connectRemoteCtrl(invitation: String): RemoteCtrlOffer { + this sends "/connect remote ctrl $invitation" + val ctrlName = eventuallyMatching(CONNECTING_NEW_CTRL, CONNECT_TIMEOUT_MS).groupValues[1] + eventually("new remote controller connected", CONNECT_TIMEOUT_MS) + this expects "Compare session code with controller and use:" + val verify = nextLine() + if (!verify.startsWith(VERIFY_CTRL)) throw AssertionError("$name: expected `$VERIFY_CTRL`, got `$verify`; next: ${pending()}") + return RemoteCtrlOffer(ctrlName, verify.removePrefix(VERIFY_CTRL)) +} + +fun CliPeer.verifyRemoteCtrl(sessionCode: String): String { + this sends "$VERIFY_CTRL$sessionCode" + return eventuallyMatching(CTRL_SESSION_STARTED, CONNECT_TIMEOUT_MS).groupValues[1] +} + +fun CliPeer.stopRemoteCtrl() { + this sends "/stop remote ctrl" + eventually("ok") +} + +fun CliPeer.startRemoteHost(port: Int): String { + this sends "/start remote host new addr=127.0.0.1 iface=\"lo\" port=$port" + eventually("new remote host started on 127.0.0.1:$port", CONNECT_TIMEOUT_MS) + eventually("Remote session invitation:") + return nextLine() +} + +fun CliPeer.awaitRemoteHostCode(): String { + eventually("new remote host connecting", CONNECT_TIMEOUT_MS) + this expects "Compare session code with host:" + return nextLine() +} + +fun CliPeer.awaitNewRemoteHost(): String { + val added = eventuallyMatching(NEW_HOST_ADDED, CONNECT_TIMEOUT_MS) + eventuallyMatching(Regex("remote host ${added.groupValues[1]} connected \\((with|no) compression\\)"), CONNECT_TIMEOUT_MS) + return added.groupValues[2] +} + +fun UiScope.linkMobile(app: App): LinkMobileScreen { + app.userPicker() + click(Ui.UserPicker.LinkMobile) + waitFor(Ui.Remote.AddMobile) + return LinkMobileScreen(this) +} + +class LinkMobileScreen(private val s: UiScope) { + fun deviceName(): String? { + s.waitFor(Ui.Remote.DeviceName) + return s.editableText(Ui.Remote.DeviceName) + } + + fun addMobile(): String { + s.click(Ui.Remote.AddMobile) + s.poll(timeoutMs = 30_000, what = "the invitation QR code with `Waiting for mobile to connect:` and `Share link`") { + s.seesDescription("QR Code") && s.seesText("Waiting for mobile to connect:") && s.exists(Ui.Remote.ShareLink) + } + s.click(Ui.Remote.ShareLink) + var invitation: String? = null + s.poll(what = "the invitation on the clipboard") { + invitation = s.clipboardText()?.takeIf { it.startsWith("xrcp:/") } + invitation != null + } + return invitation!! + } +} + +fun UiScope.awaitMobileSession(hostDeviceName: String): Long { + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the desktop to switch to the mobile `$hostDeviceName` and close the pairing screen") { + chatModel.currentRemoteHost.value?.hostDeviceName == hostDeviceName && !chatModel.switchingUsersAndHosts.value && + !exists(Ui.Remote.AddMobile) && !exists(Ui.Remote.DeviceName) + } + quiesce() + return chatModel.currentRemoteHost.value!!.remoteHostId +} + +fun UiScope.linkMobileHost(app: App, mobile: CliPeer, hostDeviceName: String): Long { + val offer = mobile.connectRemoteCtrl(linkMobile(app).addMobile()) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the session code `${offer.sessionCode.take(23)}` on the pairing screen") { seesText(offer.sessionCode.take(23)) } + mobile.verifyRemoteCtrl(offer.sessionCode) + return awaitMobileSession(hostDeviceName) +} + +fun UiScope.endMobileSessionViaApi(mobile: CliPeer) { + val host = chatModel.currentRemoteHost.value ?: error("the desktop has no mobile session to end") + chatController.stopRemoteHostAndReloadHosts(host, switchToLocal = true) + mobile.eventually("remote controller stopped", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the desktop back on its own profile") { + chatModel.currentRemoteHost.value == null && !chatModel.switchingUsersAndHosts.value + } + quiesce() +} + +fun UiScope.useFromDesktop(app: App): ConnectDesktopScreen { + app.userPicker() + click(Ui.UserPicker.UseFromDesktop) + waitFor(Ui.Remote.DeviceName) + return ConnectDesktopScreen(this) +} + +class ConnectDesktopScreen(private val s: UiScope) { + fun deviceName(): String? { + s.waitFor(Ui.Remote.DeviceName) + return s.editableText(Ui.Remote.DeviceName) + } + + fun paste(invitation: String) { + s.click(Ui.Remote.PasteAddress) + s.poll(what = "the pasted desktop address in the address field") { s.seesText(invitation) } + } + + fun connect() = s.click(Ui.Remote.Connect) + + fun confirm(sessionCode: String) { + s.poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Verify connection` with the session code `${sessionCode.take(23)}`") { + s.seesText("Verify connection") && s.seesText(sessionCode.take(23)) && s.exists(Ui.Remote.Confirm) + } + s.click(Ui.Remote.Confirm) + } + + fun awaitConnected(desktopName: String, sessionCode: String) = + s.poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Connected to desktop` with `$desktopName` and the session code `${sessionCode.take(23)}`") { + s.seesText("Connected to desktop") && s.seesText("Connected desktop") && s.seesText(desktopName) && s.seesText(sessionCode.take(23)) && s.exists(Ui.Remote.Disconnect) + } +} + +fun UiScope.linkToDesktop(app: App, controller: CliPeer, desktopName: String, port: Int): ConnectDesktopScreen { + val invitation = controller.startRemoteHost(port) + setClipboardText(invitation) + val screen = useFromDesktop(app) + screen.paste(invitation) + screen.connect() + val code = controller.awaitRemoteHostCode() + screen.confirm(code) + controller.awaitNewRemoteHost() + screen.awaitConnected(desktopName, code) + return screen +} + +fun UiScope.endDesktopSessionViaApi(controller: CliPeer) { + check(runBlocking { chatController.stopRemoteCtrl() }) { "stopRemoteCtrl failed" } + runBlocking { chatController.switchToLocalSession() } + controller.eventuallyMatching(Regex("remote host \\d+ stopped"), CONNECT_TIMEOUT_MS) + quiesce() +} + +private const val VERIFY_CTRL = "/verify remote ctrl " +private val CONNECTING_NEW_CTRL = Regex("connecting new remote controller: (.+), v\\d\\S*.*") +private val CTRL_SESSION_STARTED = Regex("remote controller \\d+ session started with (.+) \\((with|no) compression\\)") +private val NEW_HOST_ADDED = Regex("new remote host (\\d+) added: (.+)") diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ReportScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ReportScreens.kt new file mode 100644 index 0000000000..67d5548fab --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ReportScreens.kt @@ -0,0 +1,148 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.model.MsgContentTag +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.helpers.ModalViewId +import kotlinx.coroutines.runBlocking + +fun UiScope.memberOfHostedGroup(app: App, host: CliPeer, member: CliPeer, group: String = "team"): ChatScreen { + val me = chatModel.currentUser.value!!.displayName + connect(host) + connectPeers(host, member) + hostGroup(host, group, me, member.name) + joinGroupFromList(app, host, group) + member sends "/j $group" + member.eventually("#$group: you joined the group", CONNECT_TIMEOUT_MS) + host.eventually("#$group: ${member.name} joined the group", CONNECT_TIMEOUT_MS) + member.eventually("#$group: member $me is connected", CONNECT_TIMEOUT_MS) + quiesce() + backToChatList() + return app.chatList.open(group) +} + +fun UiScope.ownGroupWithTwo(app: App, a: CliPeer, b: CliPeer, group: String = "team"): ChatScreen { + connect(a) + connect(b) + val chat = ownGroup(app, group, a, b) + a.eventuallyMatching(Regex("#$group: (new )?member ${b.name} is connected"), CONNECT_TIMEOUT_MS) + b.eventuallyMatching(Regex("#$group: (new )?member ${a.name} is connected"), CONNECT_TIMEOUT_MS) + quiesce() + return chat +} + +fun CliPeer.sendsReport(group: String, reason: String, author: String, text: String) { + this sends "/report #$group $reason $text" + expectsMessage("#$group (support) > $author $text") + this expects " report $reason" +} + +fun CliPeer.receivesReport(group: String, reporter: String, author: String, text: String, report: String) { + val line = nextLine(CONNECT_TIMEOUT_MS) + val expected = Regex("""^(\d{2}:\d{2} )?""" + Regex.escape("#$group (support: $reporter) $reporter") + """!?""" + Regex.escape("> > $author $text") + "$") + check(expected.matches(line)) { "$name: expected the report of `$text` from $reporter, got `$line`; next: ${pending()}" } + this expects " $report" +} + +private fun memberIdViaApi(group: String, name: String): Long = + runBlocking { chatController.apiListMembers(null, groupInfoOf(group).groupId) }.first { it.memberProfile.displayName == name }.groupMemberId + +fun setRoleViaApi(group: String, name: String, role: GroupMemberRole) { + val changed = runBlocking { chatController.apiMembersRole(null, groupInfoOf(group).groupId, listOf(memberIdViaApi(group, name)), role) } + check(changed.any { it.memberRole == role }) { "apiMembersRole did not make $name $role" } +} + +fun blockForAllViaApi(group: String, name: String) { + val blocked = runBlocking { chatController.apiBlockMembersForAll(null, groupInfoOf(group).groupId, listOf(memberIdViaApi(group, name)), true) } + check(blocked.any { it.blockedByAdmin }) { "apiBlockMembersForAll did not block $name" } +} + +fun UiScope.menuOffers(id: Long, action: String): Boolean { + openContextMenu(Ui.Chat.Item(id)) + waitFor(Ui.Chat.ItemMenu(id, "select")) + val offered = exists(Ui.Chat.ItemMenu(id, action)) + click(Ui.Chat.ItemMenu(id, "select")) + waitFor(Ui.Chat.Selection.part("title")) + escape() + poll(what = "selection mode to end") { !exists(Ui.Chat.Selection.part("title")) } + return offered +} + +fun UiScope.openReportReasons(id: Long) { + openContextMenu(Ui.Chat.Item(id)) + click(Ui.Chat.ItemMenu(id, "report")) + poll(what = "the `Report reason?` alert") { seesText("Report reason?") && exists(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) } +} + +fun UiScope.composeReport(id: Long, reason: String, header: String) { + openReportReasons(id) + click(Ui.Chat.ReportReason(reason)) + poll(what = "the `$reason` report in compose with `$header`") { !seesText("Report reason?") && seesText(header) } +} + +fun UiScope.sendReport(header: String) { + click(Ui.Chat.Send) + poll(what = "the report with `$header` to leave compose after sending") { !seesText(header) && editableText(Ui.Chat.Message).isNullOrEmpty() } +} + +fun UiScope.reportSentAlert() = + poll(what = "the `Report sent to moderators` alert with `OK` and `Don't show again`") { + seesText("Report sent to moderators") && seesText("You can view your reports in Chat with admins.") && + "OK" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && "Don't show again" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } + +fun UiScope.reportSendFinished() = poll(what = "the report send to finish") { UiIdle.inFlight().isEmpty() } + +fun UiScope.reportsBanner(count: String) = + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `$count` banner in the group chat") { exists(Ui.Chat.Reports) && count in textsOf(Ui.Chat.Reports) } + +fun UiScope.openReportsFromBanner(): MemberReportsScreen { + click(Ui.Chat.Reports) + return MemberReportsScreen(this) +} + +class MemberReportsScreen(private val s: UiScope) { + init { + s.poll(what = "the `Member reports` view") { + chatModel.secondaryChatsContext.value?.contentTag == MsgContentTag.Report && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT) && s.seesText("Member reports") + } + } + + fun items(): List = chatModel.secondaryChatsContext.value?.chatItems?.value.orEmpty() + + fun reportId(quoted: String): Long { + var id: Long? = null + s.poll(what = "the report of `$quoted` in `Member reports`") { + id = items().lastOrNull { it.isReport && it.quotedItem?.text == quoted }?.id + id != null && s.exists(Ui.Chat.Item(id!!)) + } + return id!! + } + + fun texts(id: Long): List = s.textsOf(Ui.Chat.Item(id)) + + fun menu(id: Long, action: String) { + s.openContextMenu(Ui.Chat.Item(id)) + s.click(Ui.Chat.ItemMenu(id, action)) + } + + fun archive(id: Long, option: String) { + menu(id, "archive") + val forMe = Ui.Alert.Option(Ui.AlertSource.Shared, "archiveForMe") + val forAll = Ui.Alert.Option(Ui.AlertSource.Shared, "archiveForAll") + s.poll(what = "the `Archive report?` alert with `For me` and `For all moderators`") { + s.seesText("Archive report?") && s.exists(forMe) && "For me" in s.textsOf(forMe) && s.exists(forAll) && "For all moderators" in s.textsOf(forAll) + } + s.click(Ui.Alert.Option(Ui.AlertSource.Shared, option)) + } + + fun close() { + s.goBack() + s.poll(what = "`Member reports` to close") { chatModel.secondaryChatsContext.value == null && !ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT) } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Screens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Screens.kt index b0e942d6da..83d13ad4a9 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Screens.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Screens.kt @@ -5,6 +5,8 @@ import chat.simplex.common.model.ChatItem import chat.simplex.common.model.GroupMemberRole import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import chat.simplex.common.views.helpers.AlertManager import chat.simplex.common.views.helpers.ModalManager class App(private val s: UiScope) { @@ -79,7 +81,11 @@ class ChatListScreen(private val s: UiScope) { } fun open(name: String): ChatScreen { - s.click(Ui.ChatList.Row(chatId(name))) + val id = chatId(name) + s.click(Ui.ChatList.Row(id)) + s.poll(what = "chat `$name` to open, or an alert instead") { + chatModel.chatId.value == id || AlertManager.shared.hasAlertsShown() || AlertManager.privacySensitive.hasAlertsShown() + } return ChatScreen(s) } @@ -89,6 +95,13 @@ class ChatListScreen(private val s: UiScope) { fun shareTo(name: String) = s.click(Ui.Share.Row(chatId(name))) + fun shareSearch(text: String) { + s.click(described("Search")) + s.typeInto(Ui.Share.Bar.part("search"), text) + } + + fun shareShown(name: String): Boolean = s.exists(Ui.Share.Row(chatId(name))) + fun rowTexts(name: String): List = s.textsOf(Ui.ChatList.Row(chatId(name))) fun rowDescriptions(name: String): List = s.descriptionsOf(Ui.ChatList.Row(chatId(name))) @@ -187,6 +200,13 @@ class AppearanceScreen(private val s: UiScope) { fun colorModeText(): String? = s.textOf(Ui.Appearance.ColorMode) + fun darkModeColors(theme: String) { + s.click(Ui.Appearance.DarkModeColors) + s.click(Ui.Appearance.DarkModeColors.option(theme)) + } + + fun darkModeColorsText(): String? = s.textOf(Ui.Appearance.DarkModeColors) + var fontScale: Float get() = s.sliderValue(Ui.Appearance.FontScale) set(v) = s.setSlider(Ui.Appearance.FontScale, v) @@ -245,6 +265,27 @@ class ChatScreen(private val s: UiScope) { s.click(Ui.Chat.ItemMenu(id, action)) } + fun select(text: String) { + menu(text, "select") + s.waitFor(Ui.Chat.Selection.part("title")) + } + + fun toggle(id: Long) = s.click(Ui.Chat.Item(id)) + + fun selectionTitle(): String? = Ui.Chat.Selection.part("title").let { k -> if (s.exists(k)) s.textsOf(k).firstOrNull() else null } + + fun itemInfo(text: String): ItemInfoScreen { + menu(text, "info") + s.poll(what = "the item info with `Sent message` and `History`") { s.seesText("Sent message") && s.seesText("History") } + return ItemInfoScreen(s) + } + + fun receivedItemInfo(text: String): ItemInfoScreen { + menu(text, "info") + s.poll(what = "the item info with `Received message`") { s.seesText("Received message") } + return ItemInfoScreen(s) + } + fun react(text: String, emoji: String) { val id = itemId(text) s.rightClick(Ui.Chat.Item(id)) @@ -261,6 +302,31 @@ class ChatScreen(private val s: UiScope) { s.click(Ui.Chat.Send) } + fun itemId(what: String, match: (ChatItem) -> Boolean): Long { + var id: Long? = null + s.poll(timeoutMs = CONNECT_TIMEOUT_MS, what = what) { + val found = chatModel.chatsContext.chatItems.value.lastOrNull(match)?.id + id = found + found != null && s.exists(Ui.Chat.Item(found)) + } + return id!! + } + + fun forwardItemTo(id: Long, chatName: String) { + s.rightClick(Ui.Chat.Item(id)) + s.click(Ui.Chat.ItemMenu(id, "forward")) + ChatListScreen(s).shareTo(chatName) + s.poll(what = "the send button to be enabled for forwarding") { s.isEnabled(Ui.Chat.Send) } + s.click(Ui.Chat.Send) + } + + fun forwardSelected() = s.click(Ui.Chat.Selection.part("forward")) + + fun sendComposed() { + s.click(Ui.Chat.Send) + s.poll(what = "the message field to clear after Send") { s.editableText(Ui.Chat.Message).isNullOrEmpty() } + } + fun openSearch() { s.click(Ui.Chat.BarMenu) s.click(Ui.Chat.BarMenuItem("search")) @@ -334,6 +400,28 @@ class GroupInfoScreen(private val s: UiScope) { return link!! } + fun preferences(): GroupPreferencesScreen { + s.click(Ui.GroupInfo.Preferences) + return GroupPreferencesScreen(s) + } + + fun chatsWithMembers(): MemberSupportScreen { + s.click(Ui.GroupInfo.MemberSupport) + return MemberSupportScreen(s) + } + + fun reportsTexts(): List = s.textsOf(Ui.GroupInfo.Reports) + + fun reports(): MemberReportsScreen { + s.click(Ui.GroupInfo.Reports) + return MemberReportsScreen(s) + } + + fun close() { + s.escape() + s.poll(what = "group info to close") { !ModalManager.end.hasModalsOpen() } + } + private fun memberId(name: String): Long { var id: Long? = null s.poll(what = "member `$name` in the model") { @@ -362,6 +450,37 @@ class MemberInfoScreen(private val s: UiScope) { s.click(Ui.MemberInfo.Remove) s.click(Ui.Alert.Option(Ui.AlertSource.Shared, "remove")) } + + fun removeWithMessages() { + s.click(Ui.MemberInfo.Remove) + val option = Ui.Alert.Option(Ui.AlertSource.Shared, "removeWithMessages") + s.poll(what = "the `Remove member?` alert with `Remove and delete messages`") { + s.seesText("Remove member?") && s.exists(option) && "Remove and delete messages" in s.textsOf(option) + } + s.click(option) + } + + fun pickRole(role: GroupMemberRole) { + s.click(Ui.MemberInfo.Role) + s.click(Ui.MemberInfo.Role.option(role)) + s.poll(what = "the `Change role?` alert with `Change`") { s.seesText("Change role?") && "Change" in s.textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + } + + fun block(name: String) = confirm(Ui.MemberInfo.Block, "Block member?", "All new messages from $name will be hidden!", "Block") + + fun unblock(name: String) = confirm(Ui.MemberInfo.Unblock, "Unblock member?", "Messages from $name will be shown!", "Unblock") + + fun unblockForAll(name: String) = confirm(Ui.MemberInfo.UnblockForAll, "Unblock member for all?", "Messages from $name will be shown!", "Unblock for all") + + private fun confirm(button: UiKey, title: String, text: String, action: String) { + s.click(button) + s.poll(what = "the `$title` alert saying `$text` with `$action`") { + s.seesText(title) && s.seesText(text) && action in s.textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + s.click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + + fun message() = s.click(Ui.MemberInfo.Message) } fun UiScope.emptyGroup(app: App, group: String): ChatScreen { diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/SendImageScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/SendImageScreens.kt new file mode 100644 index 0000000000..46353ffcba --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/SendImageScreens.kt @@ -0,0 +1,26 @@ +package chat.simplex.app.ui.harness + +import androidx.compose.ui.graphics.ImageBitmap +import chat.simplex.common.platform.compressImageData +import chat.simplex.common.ui.Ui +import java.io.File + +private const val JPEG_SIZE = 64 +private val COMPRESSED_IMAGE_OFFER = Regex("""(?:\d{2}:\d{2} )?(.+)sends file (IMG_\S+\.jpg) \(.+ / \d+ bytes\)""") + +fun UiScope.jpegFile(name: String): File = + File(exchangeDir(), name).apply { writeBytes(compressImageData(ImageBitmap(JPEG_SIZE, JPEG_SIZE), usePng = false).toByteArray()) } + +fun UiScope.attachImage(file: File) { + openAttachmentSheet() + openFile(file) { click(Ui.Chat.Attach("image")) } + poll(what = "the preview of ${file.name} with `Cancel image preview`") { seesDescription("Cancel image preview") && seesDescription("preview image") } +} + +fun CliPeer.receivesCompressedImage(sender: String, fileId: Int, timeoutMs: Long = CONNECT_TIMEOUT_MS): String { + val line = nextLine(timeoutMs) + val m = COMPRESSED_IMAGE_OFFER.matchEntire(line)?.takeIf { it.groupValues[1] == sender } + ?: throw AssertionError("$name: expected `${sender}sends file IMG_….jpg (…)`, got `$line`; next: ${pending()}") + this expects "use /fr $fileId [/ | ] to receive it" + return m.groupValues[2] +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ServersConfig.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ServersConfig.kt index f8af563602..cbec933daf 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ServersConfig.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ServersConfig.kt @@ -1,5 +1,7 @@ package chat.simplex.app.ui.harness +import chat.simplex.common.model.RelayProfile +import chat.simplex.common.model.UserChatRelay import chat.simplex.common.model.UserServer import chat.simplex.common.platform.chatController import chat.simplex.common.platform.chatModel @@ -16,3 +18,17 @@ fun configureServers(smp: String, xftp: String) { check(runBlocking { chatController.setUserServers(null, updated) }) { "setUserServers failed" } chatModel.conditions.value = runBlocking { chatController.getServerOperators(null) } ?: error("getServerOperators failed") } + +fun registerChatRelaysViaApi(vararg relays: CliPeer) { + val groups = runBlocking { chatController.getUserServers(null) } ?: error("getUserServers failed") + val added = relays.map { + UserChatRelay(chatRelayId = null, address = it.relayAddress, relayProfile = RelayProfile(displayName = it.name, fullName = ""), domains = emptyList(), preset = false, enabled = true, deleted = false) + } + val updated = groups.map { g -> if (g.operator == null) g.copy(chatRelays = g.chatRelays + added) else g } + check(updated.any { it.operator == null }) { "no custom server group to add chat relays to" } + check(runBlocking { chatController.setUserServers(null, updated) }) { "setUserServers failed" } +} + +const val SOCKS_HOST = "127.0.0.1" + +data class SocksConnection(val host: String, val port: Int, val username: String?) diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ServersSummaryScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ServersSummaryScreens.kt new file mode 100644 index 0000000000..27058b10f6 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ServersSummaryScreens.kt @@ -0,0 +1,33 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.ui.Ui + +class ServersSummaryScreen(private val s: UiScope) { + init { + s.poll(what = "`Servers info` with the loaded summary") { s.seesText("Servers info") && s.exists(Ui.ServersSummary.ResetStats) } + s.waitStable(Ui.ServersSummary.Details) + } + + fun value(title: String): String? = s.texts().let { t -> t.indexOf(title).takeIf { it >= 0 }?.let { t.getOrNull(it + 1) } } + + fun subscribed(): String? = s.texts().let { t -> t.indexOf("Subscribed").takeIf { it >= 0 }?.let { t.getOrNull(it + 3) } } + + fun details() { + s.click(Ui.ServersSummary.Details) + s.poll(what = "`Detailed statistics` with the `Subscribed` row") { s.seesText("Detailed statistics") && s.seesText("Subscribed") && !s.seesText("Connected servers") } + } + + fun backToSummary() { + s.goBack() + s.poll(what = "back on `Servers info`") { !s.seesText("Detailed statistics") && s.exists(Ui.ServersSummary.ResetStats) } + s.waitStable(Ui.ServersSummary.Details) + } +} + +fun UiScope.openServersSummary(): ServersSummaryScreen { + backToChatList() + click(Ui.ChatList.Subscriptions) + return ServersSummaryScreen(this) +} + +fun String?.isCount(): Boolean = this != null && isNotEmpty() && all(Char::isDigit) && this != "0" diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/SettingsScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/SettingsScreens.kt new file mode 100644 index 0000000000..bff740d737 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/SettingsScreens.kt @@ -0,0 +1,65 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.getPreferenceFilePath +import chat.simplex.common.platform.readThemeOverrides +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.ui.theme.DefaultTheme +import chat.simplex.common.ui.theme.ThemeOverrides +import chat.simplex.common.views.helpers.WallpaperType +import java.io.File + +fun UiScope.developerTools(app: App) { + app.settings() + click(Ui.Settings.Version) + click(Ui.Settings.DeveloperTools) + waitFor(Ui.DevTools.Console) +} + +fun UiScope.closeDeveloperTools(what: String = "the chat list", shown: UiScope.() -> Boolean = { exists(Ui.ChatList.Search) }) { + escape() + poll(what = "Developer tools to close") { !exists(Ui.DevTools.Console) && exists(Ui.Settings.DeveloperTools) } + escape() + poll(what = "App version to close") { !exists(Ui.Settings.DeveloperTools) && exists(Ui.Settings.Version) } + escape() + poll(what = "Your settings to close, showing $what") { !exists(Ui.Settings.Version) && shown() } +} + +fun UiScope.chatConsole(app: App) { + developerTools(app) + click(Ui.DevTools.Console) + waitFor(Ui.Chat.Message) +} + +fun UiScope.helpAndSupport(app: App) { + app.settings() + click(Ui.Settings.Help) + waitFor(Ui.Help.HowToUse) +} + +fun UiScope.customizeTheme(app: App) { + app.appearance() + click(Ui.Appearance.Customize) + waitFor(Ui.Appearance.ExportTheme) +} + +fun UiScope.closeAppearance() { + escape() + poll(what = "Appearance to close") { !exists(Ui.Appearance.ColorMode) && exists(Ui.Settings.Appearance) } + escape() + poll(what = "Your settings to close") { !exists(Ui.Settings.Appearance) && exists(Ui.ChatList.Search) } +} + +fun UiScope.consoleShows(label: String): Boolean = texts().any { it.endsWith(" $label") } + +object StoredThemes { + fun yamlBytes(): ByteArray? = File(getPreferenceFilePath("themes.yaml")).takeIf { it.exists() }?.readBytes() + + fun active(base: DefaultTheme): ThemeOverrides? { + val id = appPrefs.currentThemeIds.get()[base.themeName] ?: return null + return readThemeOverrides().firstOrNull { it.base == base && it.themeId == id } + } +} + +fun currentWallpaperPreset(): String? = (CurrentColors.value.wallpaper.type as? WallpaperType.Preset)?.filename diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/SupportChatScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/SupportChatScreens.kt new file mode 100644 index 0000000000..8750cfe4a3 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/SupportChatScreens.kt @@ -0,0 +1,49 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.ui.Ui + +fun CliPeer.expectsScopedMessage(from: String, text: String, timeoutMs: Long = CONNECT_TIMEOUT_MS) { + val got = nextLine(timeoutMs) + val expected = Regex("(\\d{2}:\\d{2} )?" + Regex.escape(from) + "!?> " + Regex.escape(text)) + check(expected.matches(got)) { "$name: expected message `$from> $text` (attention marker optional), got `$got`; next: ${pending()}" } +} + +fun UiScope.memberOfBobsTeamWithCath(app: App, bob: CliPeer, cath: CliPeer): ChatScreen { + connect(bob) + connectPeers(bob, cath) + hostGroup(bob, "team", "alice", "cath") + joinGroupFromList(app, bob, "team") + cath sends "/j team" + cath.eventually("#team: you joined the group") + bob.eventually("#team: cath joined the group") + cath.eventually("#team: member alice is connected", CONNECT_TIMEOUT_MS) + quiesce() + backToChatList() + return app.chatList.open("team") +} + +fun UiScope.ownTeamWithSupportRequests(app: App, bob: CliPeer, cath: CliPeer): ChatScreen { + connect(bob) + connect(cath) + val chat = ownGroup(app, "team", bob, cath) + bob.eventuallyMatching(Regex("#team: (new )?member cath is connected"), CONNECT_TIMEOUT_MS) + cath.eventuallyMatching(Regex("#team: (new )?member bob is connected"), CONNECT_TIMEOUT_MS) + poll(what = "team's chat open with no support banner") { exists(Ui.Chat.Message) && !exists(Ui.Chat.SupportChats) } + bob sends "#team (support) bob needs help" + cath sends "#team (support) cath needs help" + return chat +} + +fun UiScope.supportBanner(): List = if (exists(Ui.Chat.SupportChats)) textsOf(Ui.Chat.SupportChats) else emptyList() + +fun UiScope.supportRowBadge(name: String): String? { + val row = Ui.MemberSupport.Row(groupMemberId(name)) + return if (exists(row)) unreadBadge(row) else null +} + +fun UiScope.openSupportRowMenu(name: String): Long { + val id = groupMemberId(name) + openContextMenu(Ui.MemberSupport.Row(id)) + waitFor(Ui.MemberSupport.RowMenu(id, "delete")) + return id +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/AddressTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/AddressTest.kt new file mode 100644 index 0000000000..f522083d17 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/AddressTest.kt @@ -0,0 +1,264 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.AddressScreen +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.addressPlanOk +import chat.simplex.app.ui.harness.addressSettings +import chat.simplex.app.ui.harness.addressShared +import chat.simplex.app.ui.harness.awaitAddressGone +import chat.simplex.app.ui.harness.awaitAddressScreenAfterSettings +import chat.simplex.app.ui.harness.awaitAppIdle +import chat.simplex.app.ui.harness.closeTopScreen +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.labelled +import chat.simplex.app.ui.harness.leaveAddressSettingsExpectingSaveAlert +import chat.simplex.app.ui.harness.pickerRowOpens +import chat.simplex.app.ui.harness.savedAddressSettings +import chat.simplex.app.ui.harness.shareAddress +import chat.simplex.common.model.BusinessChatType +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.views.helpers.AlertManager +import org.junit.Test + +class AddressTest : UiTest() { + @Test fun addressDelete() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + alice.chatList.row("bob") + alice.userPicker().address() + click(Ui.Address.Create) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Share address with SimpleX contacts?` alert with `Share`") { + seesText("Share address with SimpleX contacts?") && "Share" in textsOf(CONFIRM) + } + click(CONFIRM) + bob.eventually("alice set new contact address, use /info alice to view", CONNECT_TIMEOUT_MS) + val address = shareAddress() + check(cath.addressPlanOk(address)) { "cath's plan rejects the address before it is deleted" } + click(Ui.Address.Delete) + poll(what = "the `Delete address?` alert saying contacts stay connected and get a profile update") { + seesText("Delete address?") && seesText("All your contacts will remain connected. Profile update will be sent to your contacts.") && "Delete" in textsOf(CONFIRM) + } + click(CONFIRM) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the address screen back to `Create SimpleX address`") { + chatModel.userAddress.value == null && exists(Ui.Address.Create) && !exists(Ui.Address.Delete) + } + bob.eventually("alice removed contact address", CONNECT_TIMEOUT_MS) + cath.awaitAddressGone(address) + cath sends "/c $address" + cath.nextLine(CONNECT_TIMEOUT_MS).let { check("error: connection authorization failed" in it) { "cath's `/c` via the deleted address printed `$it`" } } + } + + @Test fun addressAutoAcceptWelcomeMessage() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath"); val dave = cli("dave") + val address = alice.userPicker().createAddress() + bob sends "/c $address" + bob expects "connection request sent!" + alice.chatList.request("bob") + alice.userPicker().address() + val settings = addressSettings() + settings.toggleAutoAccept() + poll(what = "auto-accept saved") { savedAddressSettings()?.autoAccept != null } + settings.welcome("hello!") + settings.save() + poll(what = "the welcome message saved") { savedAddressSettings()?.autoReply?.text == "hello!" } + cath sends "/c $address" + cath expects "connection request sent!" + cath.eventuallyAll(listOf("alice: contact is connected"), messages = listOf("alice> hello!"), timeoutMs = CONNECT_TIMEOUT_MS) + settings.toggleAutoAccept() + poll(what = "auto-accept off saved with no welcome message") { savedAddressSettings()?.let { it.autoAccept == null && it.autoReply == null } == true } + settings.close() + AddressScreen(this).close() + dave sends "/c $address" + dave expects "connection request sent!" + alice.chatList.request("dave") + alice.chatList.request("bob") + poll(what = "cath connected as a contact without a request") { + (alice.chatList.chat("cath").chatInfo as? ChatInfo.Direct)?.contact?.let { it.sndReady && !it.nextAcceptContactRequest } == true + } + } + + @Test fun addressBusinessToggleAutoEnablesAutoAccept() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val address = alice.userPicker().address().create() + waitFor(Ui.Address.Business) + check(!seesText(TEAM_FOOTER) && savedAddressSettings()?.let { !it.businessAddress && it.autoAccept == null } == true) { "a new address is a business address or auto-accepts" } + click(Ui.Address.Business) + poll(what = "the business footer shown and business with auto-accept saved") { + seesText(TEAM_FOOTER) && savedAddressSettings()?.let { it.businessAddress && it.autoAccept != null } == true + } + val settings = addressSettings() + check(!settings.autoAcceptEnabled()) { "`Auto-accept` can be turned off for a business address" } + settings.close() + bob sends "/c $address" + bob expects "connection request sent!" + bob.eventually("#alice: joining the group...", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "a customer business chat with bob") { + chatModel.chats.value.any { (it.chatInfo as? ChatInfo.Group)?.groupInfo?.businessChat?.chatType == BusinessChatType.Customer && it.chatInfo.chatViewName == "bob" } + } + click(Ui.Address.Business) + poll(what = "the business footer gone and a plain auto-accepting address saved") { + !seesText(TEAM_FOOTER) && savedAddressSettings()?.let { !it.businessAddress && it.autoAccept != null } == true + } + cath sends "/c $address" + cath expects "connection request sent!" + cath.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "cath connected as a direct contact") { + chatModel.chats.value.any { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.displayName == "cath" && c.sndReady } == true } + } + } + + @Test fun addressScreenNoAddressShowsCreate() = uiTest { + val alice = app("alice") + alice.userPicker().address() + poll(what = "`For social media` › `Create SimpleX address`, `Or to share privately` › `Create 1-time link`, and `SimpleX address or 1-time link?`") { + seesText("For social media") && labelled(Ui.Address.Create, "Create SimpleX address") && + seesText("Or to share privately") && labelled(Ui.Address.CreateLink, "Create 1-time link") && + labelled(Ui.Address.LearnMore, "SimpleX address or 1-time link?") && !exists(Ui.Address.Share) && !exists(Ui.Address.Delete) + } + click(Ui.Address.LearnMore) + poll(what = "`Address or 1-time link?` with its three sections") { + seesText("Address or 1-time link?") && seesText("Share address publicly") && seesText("Share 1-time link with a friend") && + seesText("Connection security") && !exists(Ui.Address.Create) + } + closeTopScreen("`Address or 1-time link?`") { !seesText("Address or 1-time link?") && exists(Ui.Address.Create) } + click(Ui.Address.CreateLink) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "New chat on the 1-time link with the link created") { + seesText("Share this 1-time invite link") && !editableText(Ui.Link.Text).isNullOrBlank() && chatModel.showingInvitation.value != null && + !exists(Ui.Address.CreateLink) + } + check(chatModel.userAddress.value == null) { "`Create 1-time link` created a SimpleX address" } + } + + @Test fun createAddressNoContactsSharesAutomatically() = uiTest { + val alice = app("alice"); val bob = cli("bob") + alice.userPicker() + poll(what = "the user picker row `Create SimpleX address`") { labelled(Ui.UserPicker.Address, "Create SimpleX address") } + click(Ui.UserPicker.Address) + click(Ui.Address.Create) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the address created and shared with no alert") { + chatModel.userAddress.value != null && exists(Ui.Address.Share) && addressShared() && UiIdle.inFlight().isEmpty() + } + check(!AlertManager.shared.hasAlertsShown() && !seesText("Share address with SimpleX contacts?")) { "an alert asked to share the address with no contacts" } + AddressScreen(this).close() + alice.userPicker() + poll(what = "the user picker row `Your SimpleX address`") { labelled(Ui.UserPicker.Address, "Your SimpleX address") && !labelled(Ui.UserPicker.Address, "Create SimpleX address") } + click(Ui.UserPicker.Address) + poll(what = "the address screen for the existing address") { exists(Ui.Address.Share) && exists(Ui.Address.Delete) && !exists(Ui.Address.Create) } + connect(bob) + bob sends "/i @alice" + bob.eventuallyMatching(Regex("contact address: \\S+")) + } + + @Test fun addressSettingsShareWithContactsOnOff() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + alice.userPicker().address() + click(Ui.Address.Create) + awaitShareAlert(CONNECT_TIMEOUT_MS) + click(DISMISS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the address created and not shared after `Cancel`") { + chatModel.userAddress.value != null && exists(Ui.Address.Settings) && !AlertManager.shared.hasAlertsShown() && UiIdle.inFlight().isEmpty() + } + check(!addressShared()) { "`Cancel` on `Share address with SimpleX contacts?` shared the address" } + addressSettings() + + click(Ui.Address.ShareWithContacts) + awaitShareAlert() + click(DISMISS) + poll(what = "the share alert closed") { !AlertManager.shared.hasAlertsShown() } + awaitAppIdle("`Cancel` on the share alert") + check(!addressShared()) { "`Cancel` on the share alert shared the address" } + click(Ui.Address.ShareWithContacts) + awaitShareAlert() + click(CONFIRM) + bob.eventually("alice set new contact address, use /info alice to view", CONNECT_TIMEOUT_MS) + poll(what = "the address shared") { addressShared() } + + click(Ui.Address.ShareWithContacts) + awaitStopSharingAlert() + click(DISMISS) + poll(what = "the stop-sharing alert closed") { !AlertManager.shared.hasAlertsShown() } + awaitAppIdle("`Cancel` on the stop-sharing alert") + check(addressShared()) { "`Cancel` on `Stop sharing address?` stopped sharing" } + click(Ui.Address.ShareWithContacts) + awaitStopSharingAlert() + click(CONFIRM) + bob.eventually("alice removed contact address", CONNECT_TIMEOUT_MS) + poll(what = "the address no longer shared") { !addressShared() } + } + + @Test fun addressShareViaChat() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = alice.userPicker().createAddress() + connect(bob) + val bobId = alice.chatList.chatId("bob") + pickerRowOpens(alice, Ui.UserPicker.Address, "the address screen") { exists(Ui.Address.ShareViaChat) } + click(Ui.Address.ShareViaChat) + poll(what = "the share list with bob and without the notes folder") { + alice.chatList.shareShown("bob") && !alice.chatList.shareShown("Private notes") && !exists(Ui.Address.ShareViaChat) + } + alice.chatList.shareTo("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's chat with the address card ready to send and no typed text") { + chatModel.chatId.value == bobId && editableText(Ui.Chat.Message).isNullOrEmpty() && isEnabled(Ui.Chat.Send) + } + click(Ui.Chat.Send) + bob.eventuallyMessage("alice> contact address of @alice (signed):", CONNECT_TIMEOUT_MS) + val link = bob.nextLine().trim() + check(link == address) { "bob got the link `$link`, not alice's address `$address`" } + } + + @Test fun addressSettingsUnsavedChangesAlertExitAndSave() = uiTest { + val alice = app("alice") + alice.userPicker().address().create() + val first = addressSettings() + poll(what = "an empty welcome message") { editableText(Ui.Address.WelcomeMessage).isNullOrEmpty() } + awaitAppIdle("creating and sharing the address") + val saved = chatModel.userAddress.value + click(Ui.Address.SaveSettings) + awaitAppIdle("`Save` with unchanged settings") + check(chatModel.userAddress.value === saved) { "`Save` saved unchanged address settings" } + + first.welcome("first") + leaveAddressSettingsExpectingSaveAlert() + click(DISMISS) + awaitAddressScreenAfterSettings() + check(chatModel.userAddress.value === saved && savedAddressSettings()?.autoReply == null) { "`Exit without saving` saved the welcome message" } + + val settings = addressSettings() + poll(what = "the welcome message still empty after `Exit without saving`") { editableText(Ui.Address.WelcomeMessage).isNullOrEmpty() } + settings.welcome("second") + leaveAddressSettingsExpectingSaveAlert() + click(CONFIRM) + awaitAddressScreenAfterSettings() + poll(what = "the welcome message `second` saved") { savedAddressSettings()?.autoReply?.text == "second" } + + val again = addressSettings() + poll(what = "the saved welcome message shown") { editableText(Ui.Address.WelcomeMessage) == "second" } + again.replaceWelcome("second again") + again.save() + poll(what = "the welcome message `second again` saved") { savedAddressSettings()?.autoReply?.text == "second again" } + again.close() + } + + private fun UiScope.awaitShareAlert(timeoutMs: Long = 10_000) = + poll(timeoutMs = timeoutMs, what = "the `Share address with SimpleX contacts?` alert with `Share` and `Cancel`") { + seesText("Share address with SimpleX contacts?") && labelled(CONFIRM, "Share") && labelled(DISMISS, "Cancel") && !seesText("Stop sharing address?") + } + + private fun UiScope.awaitStopSharingAlert() = + poll(what = "the `Stop sharing address?` alert with `Stop sharing` and `Cancel`") { + seesText("Stop sharing address?") && labelled(CONFIRM, "Stop sharing") && labelled(DISMISS, "Cancel") && !seesText("Share address with SimpleX contacts?") + } + + private companion object { + val CONFIRM = Ui.Alert.Confirm(Ui.AlertSource.Shared) + val DISMISS = Ui.Alert.Dismiss(Ui.AlertSource.Shared) + const val TEAM_FOOTER = "Add your team members to the conversations." + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListMenusPendingTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListMenusPendingTest.kt index 8c62ffb0be..e0d4289595 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListMenusPendingTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListMenusPendingTest.kt @@ -114,19 +114,6 @@ class ChatListMenusPendingTest : UiTest() { poll(what = "the row named `friend`") { "friend" in alice.chatList.rowTexts("friend") } } - @Test fun pendingConnectionClickOpensInfo() = uiTest { - val alice = app("alice") - keepOneTimeLink(alice) - poll(what = "the pending row `invited to connect` / `you shared one-time link` and no pending connection info") { - alice.chatList.rowTexts(PENDING).containsAll(listOf(PENDING, "you shared one-time link")) && !seesText("You invited a contact") - } - val id = alice.chatList.chatId(PENDING) - alice.chatList.open(PENDING) - poll(what = "`You invited a contact` with the one-time link and the name field") { - chatModel.chatId.value == id && seesText("You invited a contact") && seesText("One-time invitation link") && exists(Ui.Info.Alias) - } - } - @Test fun pendingConnectionMenuDeleteInitiated() = uiTest { val alice = app("alice"); val bob = cli("bob") val link = keepOneTimeLink(alice) diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListToolbarSearchTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListToolbarSearchTest.kt index 3a349a2950..51a9c05c6d 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListToolbarSearchTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListToolbarSearchTest.kt @@ -194,6 +194,19 @@ class ChatListToolbarSearchTest : UiTest() { } click(Ui.Alert.Dismiss(Ui.AlertSource.PrivacySensitive)) poll(what = "the search field cleared and editable") { editableText(Ui.ChatList.Search).isNullOrEmpty() && isEnabled(Ui.ChatList.Search) } + bob sends "/da" + bob expects "Your chat address is deleted - accepted contacts will remain connected." + bob expects "To create a new chat address use /ad" + enterLinkInSearch(address) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Connection link removed` alert for the deleted address") { + seesText("Connection link removed") && seesText("Your contact removed this link", substring = true) + } + check(!exists(planConfirm)) { "the plan alert opened for a deleted address" } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert to close, the search cleared and editable, and no chat with bob") { + !seesText("Connection link removed") && editableText(Ui.ChatList.Search).isNullOrEmpty() && isEnabled(Ui.ChatList.Search) && + chatModel.chats.value.none { it.chatInfo.chatViewName == "bob" } + } } @Test fun searchPasteKnownContactLinkFiltersToContact() = uiTest { diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatProfilesTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatProfilesTest.kt index accc74201a..6104018534 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatProfilesTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatProfilesTest.kt @@ -1,9 +1,30 @@ package chat.simplex.app.ui.main +import chat.simplex.app.ui.harness.PROFILES_SEARCH +import chat.simplex.app.ui.harness.UiScope import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitAppIdle +import chat.simplex.app.ui.harness.closeProfilesScreen +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.hideUserViaApi +import chat.simplex.app.ui.harness.labelled +import chat.simplex.app.ui.harness.newGroupsViaApi +import chat.simplex.app.ui.harness.openProfileMenu +import chat.simplex.app.ui.harness.sendUntilAuthFailed +import chat.simplex.app.ui.harness.typePassword +import chat.simplex.app.ui.harness.userHidden +import chat.simplex.app.ui.harness.userIdOf +import chat.simplex.app.ui.harness.userShowsNtfs +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager import org.junit.Test +private const val MUTE_ALERT = "ShowMuteProfileAlert" +private const val HIDDEN_NOTICE = "ShowHiddenProfilesNotice" +private const val SECRET_PASSWORD = "secret-pass-7" + class ChatProfilesTest : UiTest() { @Test fun profilesAddProfile() = uiTest { val alice = app("alice") @@ -13,4 +34,217 @@ class ChatProfilesTest : UiTest() { profiles.add("work") poll(what = "the profiles list with `alice` and `work`") { exists(Ui.Profiles.Add) && seesText("alice") && seesText("work") } } + + @Test fun profilesMuteUnmute() = uiTest { + val alice = app("alice") + appPrefs.showMuteProfileAlert.set(true) + alice.userPicker().profiles().apply { add("work"); close() } + val workId = userIdOf("work") + alice.userPicker().switchTo("alice") + alice.userPicker().profiles() + waitFor(Ui.Profiles.User(workId)) + poll(what = "work not muted and the mute alert stored as on") { userShowsNtfs(workId) == true && storedSetting(MUTE_ALERT) == "true" } + openProfileMenu(workId, "mute") + check(!exists(Ui.Profiles.UserMenu(workId, "unmute"))) { "`Unmute` offered for a profile that is not muted" } + click(Ui.Profiles.UserMenu(workId, "mute")) + awaitMutedAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "work muted and the alert closed") { userShowsNtfs(workId) == false && !AlertManager.shared.hasAlertsShown() } + check(storedSetting(MUTE_ALERT) == "true") { "`OK` on the mute alert turned the alert off" } + + openProfileMenu(workId, "unmute") + check(!exists(Ui.Profiles.UserMenu(workId, "mute"))) { "`Mute` offered for a muted profile" } + click(Ui.Profiles.UserMenu(workId, "unmute")) + poll(what = "work unmuted") { userShowsNtfs(workId) == true } + + openProfileMenu(workId, "mute") + click(Ui.Profiles.UserMenu(workId, "mute")) + awaitMutedAlert() + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "work muted and the mute alert stored as off") { + userShowsNtfs(workId) == false && storedSetting(MUTE_ALERT) == "false" && !AlertManager.shared.hasAlertsShown() + } + + openProfileMenu(workId, "unmute") + click(Ui.Profiles.UserMenu(workId, "unmute")) + poll(what = "work unmuted again") { userShowsNtfs(workId) == true } + openProfileMenu(workId, "mute") + click(Ui.Profiles.UserMenu(workId, "mute")) + poll(what = "work muted again") { userShowsNtfs(workId) == false } + openProfileMenu(workId, "unmute") + check(!AlertManager.shared.hasAlertsShown() && !seesText("Muted when inactive!")) { "the mute alert was shown after `Don't show again`" } + + restart().userPicker().profiles() + waitFor(Ui.Profiles.User(workId)) + openProfileMenu(workId, "unmute") + check(userShowsNtfs(workId) == false && !exists(Ui.Profiles.UserMenu(workId, "mute"))) { "work is not muted after restart" } + } + + @Test fun profilesDeleteActiveSwitchesToNext() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val aliceId = userIdOf("alice") + alice.userPicker().profiles().apply { add("work"); close() } + newGroupsViaApi("team") + val workId = userIdOf("work") + alice.userPicker().switchTo("alice") + alice.chatList.row("bob") + val profiles = alice.userPicker().profiles() + poll(what = "alice and work in the profiles list") { exists(Ui.Profiles.User(aliceId)) && exists(Ui.Profiles.User(workId)) } + openProfileMenu(aliceId, "delete") + click(Ui.Profiles.UserMenu(aliceId, "delete")) + val withConnections = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteWithConnections") + poll(what = "the `Delete chat profile?` alert with `Profile and server connections`") { seesText("Delete chat profile?") && exists(withConnections) } + click(withConnections) + poll(what = "work active, alice gone from the users and the profiles list") { + chatModel.currentUser.value?.userId == workId && chatModel.users.none { it.user.userId == aliceId } && + !exists(Ui.Profiles.User(aliceId)) && exists(Ui.Profiles.User(workId)) + } + profiles.close() + alice.chatList.row("team") + poll(what = "alice's contact bob gone from the chats") { chatModel.chats.value.none { it.chatInfo.chatViewName == "bob" } } + sendUntilAuthFailed(bob, "alice") + } + + @Test fun profilesListShowsAllVisible() = uiTest { + val alice = app("alice") + newGroupsViaApi("team") + val aliceId = userIdOf("alice") + alice.userPicker().profiles().apply { add("work"); close() } + newGroupsViaApi("office") + val workId = userIdOf("work") + alice.userPicker().profiles().apply { add("secret"); close() } + val secretId = userIdOf("secret") + hideUserViaApi(secretId, SECRET_PASSWORD) + alice.userPicker().switchTo("alice") + alice.chatList.row("team") + + alice.userPicker().profiles() + poll(what = "alice and work listed with `Add profile` and `Tap to activate profile.`, the hidden secret not") { + exists(Ui.Profiles.User(aliceId)) && exists(Ui.Profiles.User(workId)) && !exists(Ui.Profiles.User(secretId)) && + exists(Ui.Profiles.Add) && seesText("Tap to activate profile.") + } + click(Ui.Profiles.User(workId)) + poll(what = "work active with its chats loaded") { + chatModel.currentUser.value?.userId == workId && !chatModel.switchingUsersAndHosts.value && + chatModel.chats.value.any { it.chatInfo.chatViewName == "office" } && chatModel.chats.value.none { it.chatInfo.chatViewName == "team" } + } + typeInto(PROFILES_SEARCH, "ALI") + poll(what = "only alice listed for `ALI`, without `Add profile`") { + exists(Ui.Profiles.User(aliceId)) && !exists(Ui.Profiles.User(workId)) && !exists(Ui.Profiles.User(secretId)) && !exists(Ui.Profiles.Add) + } + closeProfilesScreen() + alice.chatList.row("office") + check(!seesText("team")) { "alice's group is listed after activating work" } + } + + @Test fun profilesAddProfileInvalidName() = uiTest { + val alice = app("alice") + alice.userPicker().profiles() + click(Ui.Profiles.Add) + typeInto(Ui.NewProfile.Name, "?bob") + waitFor(INVALID_NAME) + click(Ui.NewProfile.Create) + awaitAppIdle("`Create profile` with an invalid name") + check(chatModel.users.size == 1 && exists(Ui.NewProfile.Create) && !seesText("Duplicate display name!")) { "`Create profile` acted on the invalid name `?bob`" } + click(INVALID_NAME) + poll(what = "the `Invalid name!` alert offering `bob`") { seesText("Invalid name!") && seesText("Correct name to bob?") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the name corrected to `bob` without the invalid-name icon") { editableText(Ui.NewProfile.Name) == "bob" && !exists(INVALID_NAME) && !seesText("Invalid name!") } + + replaceText(Ui.NewProfile.Name, "alice") + check(!exists(INVALID_NAME)) { "`alice` is flagged as an invalid name" } + click(Ui.NewProfile.Create) + poll(what = "the `Duplicate display name!` alert") { + seesText("Duplicate display name!") && seesText("You already have a chat profile with the same display name. Please choose another name.") + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert closed with the Create profile screen kept and no second profile") { + !seesText("Duplicate display name!") && exists(Ui.NewProfile.Create) && chatModel.users.size == 1 && chatModel.currentUser.value?.displayName == "alice" + } + } + + @Test fun profilesHiddenNoticeFirstTime() = uiTest { + val alice = app("alice") + appPrefs.showHiddenProfilesNotice.set(true) + poll(what = "the hidden-profiles notice stored as on") { storedSetting(HIDDEN_NOTICE) == "true" } + val profiles = alice.userPicker().profiles() + waitFor(Ui.Profiles.Add) + settle() + check(!seesText("Make profile private!")) { "the hidden-profiles notice was shown with one profile" } + profiles.close() + awaitPickerClosed() + appPrefs.showHiddenProfilesNotice.set(false) + alice.userPicker().profiles().apply { add("work"); close() } + awaitPickerClosed() + appPrefs.showHiddenProfilesNotice.set(true) + + alice.userPicker().profiles() + awaitHiddenNotice() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the notice closed and still on") { !seesText("Make profile private!") && storedSetting(HIDDEN_NOTICE) == "true" } + profiles.close() + awaitPickerClosed() + + alice.userPicker().profiles() + awaitHiddenNotice() + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the notice closed and stored as off") { !seesText("Make profile private!") && storedSetting(HIDDEN_NOTICE) == "false" } + profiles.close() + awaitPickerClosed() + + alice.userPicker().profiles() + waitFor(Ui.Profiles.Add) + settle() + check(!seesText("Make profile private!")) { "the hidden-profiles notice was shown after `Don't show again`" } + } + + @Test fun profilesUnhideActiveRequiresPassword() = uiTest { + val alice = app("alice") + val aliceId = userIdOf("alice") + alice.userPicker().profiles().apply { add("work"); close() } + val workId = userIdOf("work") + hideUserViaApi(workId, SECRET_PASSWORD) + alice.userPicker().profiles() + poll(what = "the active hidden work and alice listed") { exists(Ui.Profiles.User(workId)) && exists(Ui.Profiles.User(aliceId)) && userHidden(workId) } + + openProfileMenu(aliceId, "mute") + check(!exists(Ui.Profiles.UserMenu(aliceId, "hide"))) { "`Hide` offered for the only visible profile" } + click(Ui.Profiles.UserMenu(aliceId, "mute")) + poll(what = "alice muted and the menu closed") { userShowsNtfs(aliceId) == false && !exists(Ui.Profiles.UserMenu(aliceId, "delete")) } + + openProfileMenu(workId, "unhide") + check(!exists(Ui.Profiles.UserMenu(workId, "hide")) && !exists(Ui.Profiles.UserMenu(workId, "mute"))) { "the hidden profile's menu offers `Hide` or `Mute`" } + click(Ui.Profiles.UserMenu(workId, "unhide")) + poll(what = "`Unhide profile` asking for `Profile password` with `Unhide chat profile`") { + seesText("Unhide profile") && seesText("Profile password") && exists(Ui.ProfileAction.Password) && labelled(Ui.ProfileAction.Submit, "Unhide chat profile") + } + typePassword(Ui.ProfileAction.Password, SECRET_PASSWORD.dropLast(1)) + click(Ui.ProfileAction.Submit) + awaitAppIdle("`Unhide chat profile` with a wrong password") + check(userHidden(workId) && exists(Ui.ProfileAction.Submit)) { "`Unhide chat profile` acted on a wrong password" } + typePassword(Ui.ProfileAction.Password, SECRET_PASSWORD.takeLast(1)) + click(Ui.ProfileAction.Submit) + poll(what = "work unhidden and the Unhide profile screen closed") { !userHidden(workId) && !exists(Ui.ProfileAction.Password) } + poll(what = "work listed as a visible profile") { exists(Ui.Profiles.User(workId)) && chatModel.currentUser.value?.userId == workId } + } + + private fun UiScope.awaitPickerClosed() = + poll(what = "the user picker to close after `Your chat profiles` closed") { !exists(Ui.UserPicker.Sheet) && exists(Ui.ChatList.Search) } + + private fun UiScope.awaitHiddenNotice() = + poll(what = "the `Make profile private!` notice with `OK` and `Don't show again`") { + seesText("Make profile private!") && seesText("You can hide or mute a user profile - hold it for the menu.") && + labelled(Ui.Alert.Confirm(Ui.AlertSource.Shared), "OK") && labelled(Ui.Alert.Dismiss(Ui.AlertSource.Shared), "Don't show again") + } + + private fun UiScope.awaitMutedAlert() = + poll(what = "the `Muted when inactive!` alert with `OK` and `Don't show again`") { + seesText("Muted when inactive!") && seesText("You will still receive calls and notifications from muted profiles when they are active.") && + "Don't show again" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } + + private companion object { + val INVALID_NAME = Ui.NewProfile.Name.part("invalid") + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ConnectPlanTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ConnectPlanTest.kt new file mode 100644 index 0000000000..c25a81c5f1 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ConnectPlanTest.kt @@ -0,0 +1,237 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.PeerFlags +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.chatLinkItemId +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.createAddress +import chat.simplex.app.ui.harness.createFullAddress +import chat.simplex.app.ui.harness.createFullOneTimeLink +import chat.simplex.app.ui.harness.enterLinkInSearch +import chat.simplex.app.ui.harness.joinLink +import chat.simplex.app.ui.harness.keepOneTimeLink +import chat.simplex.app.ui.harness.openPasteLink +import chat.simplex.app.ui.harness.planConfirm +import chat.simplex.app.ui.harness.planDismiss +import chat.simplex.app.ui.harness.planOption +import chat.simplex.app.ui.harness.quiesce +import chat.simplex.app.ui.harness.searchShowsLink +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.MsgContent +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ConnectPlanTest : UiTest() { + @Test fun pasteLinkInvalidTextAlert() = uiTest { + assumeCamera() + val alice = app("alice") + val address = alice.userPicker().createAddress() + openPasteLink(alice) + setClipboardText(null) + click(Ui.Link.Paste) + settle() + check(exists(Ui.Link.Paste) && "Tap to paste link" in textsOf(Ui.Link.Paste)) { "the paste row is gone after a tap with an empty clipboard" } + check(!seesText("Invalid link!") && !exists(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && !exists(planConfirm)) { "an alert opened for an empty clipboard" } + setClipboardText("https://example.com/invite") + click(Ui.Link.Paste) + poll(what = "the `Invalid link!` alert for a web link") { seesText("Invalid link!") && seesText("The text you pasted is not a SimpleX link.") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert to close with the paste row still offered") { !seesText("Invalid link!") && exists(Ui.Link.Paste) } + setClipboardText(address) + click(Ui.Link.Paste) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Connect to yourself?` alert with the pasted link in place of the row") { + seesText("Connect to yourself?") && !exists(Ui.Link.Paste) + } + click(planOption("cancel")) + poll(what = "the alert to close and the paste row to return") { !seesText("Connect to yourself?") && exists(Ui.Link.Paste) } + } + + @Test fun planOwnInvitationConnectToYourself() = uiTest { + val alice = app("alice") + val address = alice.userPicker().createAddress() + alice.chatList.row("Private notes") + check(chatModel.chats.value.none { it.chatInfo.chatViewName == "alice" }) { "a chat named alice exists before alice connects to herself" } + enterLinkInSearch(address) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Connect to yourself?` alert for alice's own address with three buttons") { + seesText("Connect to yourself?") && seesText("This is your own SimpleX address!", substring = true) && + exists(planOption("useCurrentProfile")) && exists(planOption("useIncognito")) && exists(planOption("cancel")) + } + click(planOption("useCurrentProfile")) + poll(what = "`Connection request sent!` for an address") { + seesText("Connection request sent!") && seesText("You will be connected when your connection request is accepted, please wait or check later!") + } + click(planConfirm) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice's own contact request in the model") { chatModel.chats.value.any { it.chatInfo.chatViewName == "alice" } } + alice.chatList.request("alice") + poll(what = "the search field cleared and editable") { editableText(Ui.ChatList.Search).isNullOrEmpty() && isEnabled(Ui.ChatList.Search) } + + val pendingIds = { chatModel.chats.value.filter { it.chatInfo is ChatInfo.ContactConnection }.map { it.id }.toSet() } + val contactsNamedAlice = { + chatModel.chats.value.filter { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.profile.displayName == "alice" && c.sndReady } == true } + } + val pendingBefore = pendingIds() + val link = keepOneTimeLink(alice) + check(contactsNamedAlice().isEmpty()) { "a connected contact named alice exists before alice uses her own 1-time link" } + enterLinkInSearch(link) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Connect to yourself?` alert for alice's own 1-time link") { + seesText("Connect to yourself?") && seesText("This is your own one-time link!", substring = true) + } + click(planOption("useCurrentProfile")) + poll(what = "`Connection request sent!` for a 1-time link") { + seesText("Connection request sent!") && seesText("You will be connected when your contact's device is online, please wait or check later!") + } + click(planConfirm) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "two contacts named alice connected to each other and the 1-time link no longer pending") { + contactsNamedAlice().let { c -> c.size == 2 && c.all { exists(Ui.ChatList.Row(it.id)) } } && pendingIds() == pendingBefore + } + } + + @Test fun planInvitationAlreadyConnectingFullLink() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(fullLinks = true)) + val full = bob.createFullOneTimeLink() + bob.stop() + alice.chatList.row("Private notes") + val pending = { chatModel.chats.value.count { it.chatInfo is ChatInfo.ContactConnection } } + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Connect via one-time link?` alert for the full link") { + searchShowsLink("SimpleX one-time invitation") && seesText("Connect via one-time link?") && + seesText("Your profile will be sent to the contact that you received this link from.", substring = true) + } + click(planOption("cancel")) + poll(what = "the alert to close and the search to clear") { + !seesText("Connect via one-time link?") && editableText(Ui.ChatList.Search).isNullOrEmpty() && isEnabled(Ui.ChatList.Search) + } + check(pending() == 0) { "Cancel created a pending connection" } + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Connect via one-time link?` alert again") { seesText("Connect via one-time link?") } + click(planOption("useCurrentProfile")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Connection request sent!` for a 1-time link") { + seesText("Connection request sent!") && seesText("You will be connected when your contact's device is online, please wait or check later!") + } + click(planConfirm) + poll(what = "one pending connection and the search cleared") { + pending() == 1 && editableText(Ui.ChatList.Search).isNullOrEmpty() && isEnabled(Ui.ChatList.Search) + } + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Already connecting!` for the same link while bob is offline") { + seesText("Already connecting!") && seesText("You are already connecting via this one-time link!", substring = true) && !seesText("Connect via one-time link?") + } + click(planConfirm) + poll(what = "the alert to close with still one pending connection") { !seesText("Already connecting!") && pending() == 1 } + bob.start() + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob connected and no pending connection left") { + chatModel.chats.value.any { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.displayName == "bob" && c.sndReady } == true } && pending() == 0 + } + alice.chatList.row("bob") + } + + @Test fun planAddressRepeatConnectionRequest() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(fullLinks = true)) + val full = bob.createFullAddress() + alice.chatList.row("Private notes") + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Connect via contact address?` alert for the full address") { + searchShowsLink("SimpleX contact address") && seesText("Connect via contact address?") && + seesText("Your profile will be sent to the contact that you received this link from.", substring = true) + } + click(planOption("useIncognito")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Connection request sent!` for an address") { + seesText("Connection request sent!") && seesText("You will be connected when your connection request is accepted, please wait or check later!") + } + click(planConfirm) + val rand = bob.eventuallyMatching(Regex("(\\S+) wants to connect to you!"), CONNECT_TIMEOUT_MS).groupValues[1] + assertNotEquals("alice", rand) + bob expects "to accept: /ac $rand" + bob expects "to reject: /rc $rand (the sender will NOT be notified)" + poll(what = "the search field cleared and editable") { editableText(Ui.ChatList.Search).isNullOrEmpty() && isEnabled(Ui.ChatList.Search) } + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Repeat connection request?` for the pending request") { + seesText("Repeat connection request?") && seesText("You have already requested connection via this address!", substring = true) && + !seesText("Connect via contact address?") + } + click(planOption("cancel")) + poll(what = "the alert to close and the search to clear") { + !seesText("Repeat connection request?") && editableText(Ui.ChatList.Search).isNullOrEmpty() && isEnabled(Ui.ChatList.Search) + } + bob sends "/ac $rand" + bob.nextLine().let { check(it.startsWith("$rand: accepting contact request")) { "bob's next line after /ac is `$it`, not the acceptance" } } + bob.eventually("$rand: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "an incognito contact bob ready to send") { + chatModel.chats.value.any { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.displayName == "bob" && c.contactConnIncognito && c.sndReady } == true } + } + } + + @Test fun planViaChatItemLinkCard() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(cath) + val bobAddress = bob.createAddress() + joinLink(bobAddress) + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + val cathAddress = cath.createAddress() + cath sends "/share address @alice" + cath.eventuallyMessage("@alice contact address of @cath (signed):") + cath expects cathAddress + bob sends "/share address @alice" + bob.eventuallyMessage("@alice contact address of @bob (signed):") + bob expects bobAddress + val bobId = alice.chatList.chatId("bob"); val cathId = alice.chatList.chatId("cath") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "both address cards received") { + listOf(bobId, cathId).all { id -> chatModel.chats.value.firstOrNull { it.id == id }?.chatItems?.lastOrNull()?.content?.msgContent is MsgContent.MCChat } + } + quiesce() + + enterLinkInSearch(cathAddress) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the plan alert for cath's pasted address with `Open new chat`") { + exists(planConfirm) && "Open new chat" in textsOf(planConfirm) && seesText("cath") + } + check(!seesText("Link signature verified.")) { "a pasted address with no signature shows `Link signature verified.`" } + click(planDismiss) + poll(what = "the alert to close and the search to clear") { !exists(planConfirm) && editableText(Ui.ChatList.Search).isNullOrEmpty() } + + backToChatList() + click(Ui.ChatList.Row(cathId).part("link")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the plan alert for cath's chat-list card with `Link signature verified.`") { + seesText("cath") && seesText("Link signature verified.") && exists(planConfirm) && "Open new chat" in textsOf(planConfirm) + } + click(planDismiss) + poll(what = "the alert to close") { !exists(planConfirm) } + + val bobChat = alice.chatList.open("bob") + poll(what = "bob's chat open") { chatModel.chatId.value == bobId } + val bobCard = chatLinkItemId() + poll(what = "bob's card with his name, `Contact address (from owner)` and `Tap to open`") { + textsOf(Ui.Chat.Item(bobCard)).containsAll(listOf("bob", "Contact address (from owner)", "Tap to open")) + } + click(Ui.Chat.Item(bobCard).part("chatLink")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the known-contact alert for bob with `Open chat`") { + exists(planConfirm) && "Open chat" in textsOf(planConfirm) && seesText("bob") && !seesText("Link signature verified.") + } + click(planConfirm) + poll(what = "bob's chat still open and no second chat named bob") { + !exists(planConfirm) && chatModel.chatId.value == bobId && chatModel.chats.value.count { it.chatInfo.chatViewName == "bob" } == 1 + } + bobChat.send("still here") + bob.expectsMessage("alice> still here", CONNECT_TIMEOUT_MS) + + backToChatList() + alice.chatList.open("cath") + poll(what = "cath's chat open") { chatModel.chatId.value == cathId } + val cathCard = chatLinkItemId() + poll(what = "cath's card with her name, `Contact address (from owner)` and `Tap to open`, and no link text") { + textsOf(Ui.Chat.Item(cathCard)).let { t -> t.containsAll(listOf("cath", "Contact address (from owner)", "Tap to open")) && cathAddress !in t } + } + click(Ui.Chat.Item(cathCard).part("chatLink")) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the plan alert for cath's card with `Link signature verified.` and `Open new chat`") { + seesText("Link signature verified.") && exists(planConfirm) && "Open new chat" in textsOf(planConfirm) + } + click(planConfirm) + poll(what = "a new prepared chat with cath open") { + chatModel.chatId.value != cathId && seesText("Tap Connect to send request") && chatModel.chats.value.count { it.chatInfo.chatViewName == "cath" } == 2 + } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactInfoTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactInfoTest.kt index d8387b73a0..0718d5c165 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactInfoTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactInfoTest.kt @@ -1,10 +1,43 @@ package chat.simplex.app.ui.main +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.CliPeer +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.TestServers +import chat.simplex.app.ui.harness.UiScope import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.clearChatFromInfo +import chat.simplex.app.ui.harness.closeContactInfo import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.connectIncognito +import chat.simplex.app.ui.harness.contactOf +import chat.simplex.app.ui.harness.contactPrefs +import chat.simplex.app.ui.harness.copiedServer +import chat.simplex.app.ui.harness.createAddress +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.infoNameText +import chat.simplex.app.ui.harness.isServer +import chat.simplex.app.ui.harness.itemStatus +import chat.simplex.app.ui.harness.loadedText +import chat.simplex.app.ui.harness.localSmpServer +import chat.simplex.app.ui.harness.markContactVerifiedViaApi +import chat.simplex.app.ui.harness.openNewChatFromSearch +import chat.simplex.app.ui.harness.setContactAliasViaApi +import chat.simplex.app.ui.harness.switchAwayFromChat +import chat.simplex.common.model.CIContent +import chat.simplex.common.model.ChatFeature import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.ChatItemTTL +import chat.simplex.common.model.FeatureAllowed +import chat.simplex.common.model.MsgFilter +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.views.helpers.ModalManager import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class ContactInfoTest : UiTest() { @@ -28,4 +61,442 @@ class ContactInfoTest : UiTest() { click(Ui.Info.VerifyCode) poll(what = "`bob is verified`") { seesText("bob is verified") } } + + @Test fun contactInfoSetLocalAlias() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + assertTrue("bob" in alice.chatList.rowTexts("bob")) + alice.chatList.open("bob").info() + typeInto(Ui.Info.Alias, "friend") + poll(what = "the row to read `friend`") { chatModel.chats.value.any { it.chatInfo.chatViewName == "friend" } } + poll(what = "the row text `friend` instead of `bob`") { alice.chatList.rowTexts("friend").let { "friend" in it && "bob" !in it } } + } + + @Test fun contactInfoClearLocalAlias() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + setContactAliasViaApi("bob", "friend") + restart() + val id = alice.chatList.chatId("friend") + poll(what = "bob's row named `friend`") { alice.chatList.rowTexts("friend").let { "friend" in it && "bob" !in it } } + alice.chatList.open("friend").info() + poll(what = "the alias `friend` in the name field of contact info") { exists(Ui.Info.Alias) && editableText(Ui.Info.Alias) == "friend" } + replaceText(Ui.Info.Alias, "") + poll(what = "bob's alias cleared in the model") { (chatModel.chats.value.first { it.id == id }.chatInfo as ChatInfo.Direct).contact.localAlias.isEmpty() } + closeContactInfo() + backToChatList() + poll(what = "bob's row named `bob` again") { alice.chatList.rowTexts("bob").let { "bob" in it && "friend" !in it } } + switchAwayFromChat(alice) + check(chatModel.chatId.value != id) { "bob is the open chat, which is listed whatever the search text" } + alice.chatList.search("fri") + poll(what = "bob hidden for the cleared alias") { editableText(Ui.ChatList.Search) == "fri" && !alice.chatList.shown("bob") } + replaceText(Ui.ChatList.Search, "bo") + poll(what = "bob listed for his name") { editableText(Ui.ChatList.Search) == "bo" && alice.chatList.shown("bob") } + } + + @Test fun contactInfoClearVerification() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + markContactVerifiedViaApi("bob") + restart() + alice.chatList.row("bob") + val info = alice.chatList.open("bob").contactInfo() + poll(what = "the row `View security code` and the shield before bob's name") { + info.verifyRowText() == "View security code" && infoNameText() == "${SHIELD}bob" + } + click(Ui.Info.VerifyCode) + poll(what = "`bob is verified` with `Clear verification`") { + seesText("bob is verified") && exists(described("Clear verification")) && !exists(Ui.VerifyCode.Mark) + } + click(described("Clear verification")) + poll(what = "the security code screen still open with `bob is not verified` and `Mark verified`") { + seesText("bob is not verified") && !seesText("bob is verified") && exists(Ui.VerifyCode.Mark) && !exists(described("Clear verification")) + } + poll(what = "bob not verified in the model") { !contactOf("bob").verified } + goBack() + poll(what = "the row `Verify security code` and no shield before bob's name") { + !exists(Ui.VerifyCode.Mark) && info.verifyRowText() == "Verify security code" && infoNameText() == "bob" + } + } + + @Test fun contactInfoHeaderShowsNameBioCopy() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.info() + poll(what = "bob's name in the header with the name field, and no full name or bio") { + infoNameText() == "bob" && exists(Ui.Info.Alias) && textsOf(Ui.Info.Header).none { FULL_NAME in it || BIO in it } + } + closeContactInfo() + bob.updateProfile() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's full name and bio in the model") { + bobProfile()?.let { it.fullName == FULL_NAME && it.shortDescr == BIO } == true + } + chat.info() + poll(what = "bob's name, full name and bio in the header") { + infoNameText() == "bob" && textsOf(Ui.Info.Header).let { t -> t.any { FULL_NAME in it } && t.any { BIO in it } } + } + setClipboardText(null) + check(clipboardText() == null) { "the clipboard is not empty before copying the name" } + click(Ui.Info.Name) + poll(what = "bob's name on the clipboard") { clipboardText() == "bob" } + } + + @KnownAppIssue("15.5 item 16", "waiting for the full name `Robert Brown` and the bio `Plays the cello` in the open contact info") + @Test fun contactInfoHeaderFollowsProfileUpdate() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.open("bob").info() + poll(what = "bob's name in the header, and no full name or bio") { + infoNameText() == "bob" && textsOf(Ui.Info.Header).none { FULL_NAME in it || BIO in it } + } + bob.updateProfile() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's full name and bio in the model") { + bobProfile()?.let { it.fullName == FULL_NAME && it.shortDescr == BIO } == true + } + poll(what = "the full name `$FULL_NAME` and the bio `$BIO` in the open contact info") { + textsOf(Ui.Info.Header).let { t -> FULL_NAME in t && t.any { BIO in it } } + } + } + + @Test fun contactInfoMuteButtonToggles() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + fun muteIcon() = "Notifications" in alice.chatList.rowDescriptions("bob") + alice.chatList.row("bob") + poll(what = "bob's row without the mute icon") { !muteIcon() && contactOf("bob").chatSettings.enableNtfs == MsgFilter.All } + val chat = alice.chatList.open("bob") + chat.info() + poll(what = "`Mute` in contact info") { exists(Ui.Info.Mute) && seesText("Mute") && !seesText("Unmute") } + click(Ui.Info.Mute) + poll(what = "`Unmute` with bob's notifications off") { seesText("Unmute") && !seesText("Mute") && contactOf("bob").chatSettings.enableNtfs == MsgFilter.None } + closeContactInfo() + backToChatList() + poll(what = "the mute icon on bob's row") { muteIcon() } + alice.chatList.open("bob").info() + poll(what = "`Unmute` in reopened contact info") { exists(Ui.Info.Mute) && seesText("Unmute") } + click(Ui.Info.Mute) + poll(what = "`Mute` with bob's notifications on") { seesText("Mute") && !seesText("Unmute") && contactOf("bob").chatSettings.enableNtfs == MsgFilter.All } + closeContactInfo() + backToChatList() + poll(what = "the mute icon gone from bob's row") { !muteIcon() } + } + + @Test fun contactInfoServersSection() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val smp = localSmpServer() + alice.chatList.open("bob").info() + poll(what = "`Receiving via` and `Sending via` the local server, and `Network status`") { + exists(Ui.Info.ReceivingVia) && exists(Ui.Info.SendingVia) && exists(described("Network status")) && + textsOf(Ui.Info.ReceivingVia).let { "Receiving via" in it && it.any { t -> smp.hostnames.first() in t } } && + textsOf(Ui.Info.SendingVia).let { "Sending via" in it && it.any { t -> smp.hostnames.first() in t } } + } + val receiving = copiedServer(Ui.Info.ReceivingVia) + check(isServer(receiving, smp)) { "`Receiving via` copied `$receiving`, not the local server $smp" } + val sending = copiedServer(Ui.Info.SendingVia) + check(isServer(sending, smp)) { "`Sending via` copied `$sending`, not the local server $smp" } + check(!seesText(NETWORK_STATUS_TEXT)) { "the network status explanation is shown before `Network status` is clicked" } + click(described("Network status")) + poll(what = "the `Network status` alert saying alice is connected to bob's receiving server") { + seesText(NETWORK_STATUS_TEXT) && exists(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the `Network status` alert to close") { !seesText(NETWORK_STATUS_TEXT) } + } + + @Test fun contactInfoSwitchAddress() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.info() + poll(what = "`Change receiving address` without `Abort changing address`") { exists(Ui.Info.SwitchAddress) && !seesText(ABORT_SWITCH) } + bob.stop() + click(Ui.Info.SwitchAddress) + poll(what = "the `Change receiving address?` alert with `Change`") { + seesText("Change receiving address?") && + seesText("Receiving address will be changed to a different server. Address change will complete after sender comes online.") && + "Change" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "contact info closed after `Change`") { !ModalManager.end.hasModalsOpen() } + chat.itemId("`changing address…` in bob's chat") { it.content is CIContent.SndConnEventContent && it.content.text == "changing address…" } + chat.info() + poll(what = "`Abort changing address` while bob is offline") { exists(Ui.Info.SwitchAddress) && seesText(ABORT_SWITCH) } + closeContactInfo() + bob.start() + bob.eventually("alice started changing address for you", CONNECT_TIMEOUT_MS) + bob.eventually("alice changed address for you", CONNECT_TIMEOUT_MS) + chat.itemId("`you changed address` in bob's chat") { it.content is CIContent.SndConnEventContent && it.content.text == "you changed address" } + chat.info() + poll(what = "`Change receiving address` back without `Abort changing address`") { exists(Ui.Info.SwitchAddress) && !seesText(ABORT_SWITCH) } + closeContactInfo() + bob sends "@alice after switch" + chat.itemId("bob's `after switch` on the new address") { it.content.text == "after switch" } + } + + @Test fun contactInfoAbortSwitchAddress() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's contact ready") { contactOf("bob").ready } + chat.info() + poll(what = "`Change receiving address` without `Abort changing address`") { exists(Ui.Info.SwitchAddress) && !exists(Ui.Info.AbortSwitchAddress) } + TestServers.stop("smp1") + click(Ui.Info.SwitchAddress) + poll(what = "the `Change receiving address?` alert with `Change`") { + seesText("Change receiving address?") && "Change" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "contact info closed after `Change`") { !ModalManager.end.hasModalsOpen() } + chat.info() + poll(what = "`Abort changing address` while smp1 is down") { exists(Ui.Info.AbortSwitchAddress) && ABORT_SWITCH in textsOf(Ui.Info.AbortSwitchAddress) } + click(Ui.Info.AbortSwitchAddress) + poll(what = "the `Abort changing address?` alert with `Abort`") { + seesText("Abort changing address?") && "Abort" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "`Abort changing address` gone and `Change receiving address` back after `Abort`") { + !seesText("Abort changing address?") && !exists(Ui.Info.AbortSwitchAddress) && exists(Ui.Info.SwitchAddress) + } + closeContactInfo() + TestServers.start("smp1") + chat.send("after abort") + bob.eventuallyMessage("alice> after abort", CONNECT_TIMEOUT_MS) + bob sends "@alice reply after abort" + chat.itemId("reply after abort") + assertTrue("alice's chat shows an address change after the abort: ${chat.itemTexts()}", chat.itemTexts().none { "changing address" in it || "changed address" in it }) + } + + @Test fun contactInfoClearChat() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("hello") + bob.expectsMessage("alice> hello", CONNECT_TIMEOUT_MS) + bob sends "@alice hi there" + val hello = chat.itemId("hello") + val hi = chat.itemId("bob's `hi there`") { it.content.text == "hi there" } + hideKeyboard() + chat.info() + clearChatFromInfo("bob") + poll(what = "contact info closed with `hello` and `hi there` gone from bob's chat") { + !ModalManager.end.hasModalsOpen() && !exists(Ui.Chat.Item(hello)) && !exists(Ui.Chat.Item(hi)) && + chatModel.chatsContext.chatItems.value.none { it.content.text == "hello" || it.content.text == "hi there" } + } + check(contactOf("bob").let { it.active && it.ready }) { "bob's contact is not active and ready after clearing the chat" } + bob sends "/tail @alice 2" + bob.expectsMessage("alice> hello") + bob.expectsMessage("@alice hi there") + bob sends "@alice after clear" + chat.itemId("bob's `after clear`") { it.content.text == "after clear" } + chat.send("still here") + bob.expectsMessage("alice> still here", CONNECT_TIMEOUT_MS) + backToChatList() + alice.chatList.row("bob") + } + + @Test fun contactInfoContactAddressSection() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob.createAddress(autoAccept = false) + alice.chatList.open("bob").info() + poll(what = "contact info without bob's address") { exists(Ui.Info.DeleteContact) && !exists(Ui.Address.Share) && !seesText(ADDRESS_FOOTER) } + bob sends "/pa on" + bob.eventually("new contact address set") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's address with `Share address…` in the open contact info") { + exists(Ui.Address.Share) && seesText(ADDRESS_FOOTER) && contactOf("bob").contactLink != null + } + setClipboardText(null) + click(Ui.Address.Share) + var shared: String? = null + poll(what = "bob's address shared") { shared = clipboardText()?.takeIf { it.isNotBlank() }; shared != null } + bob sends "/_connect plan 1 $shared" + bob.eventually("contact address: own address") + } + + @Test fun contactInfoNotReadyContactHidesSections() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = bob.createAddress(autoAccept = false) + alice.chatList.row("Private notes") + val chat = openNewChatFromSearch(address, "bob") + click(Ui.Chat.Connect) + poll(what = "`Send contact request?` with `Send request without message`") { + seesText("Send contact request?") && "Send request without message" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.eventuallyAll( + listOf("alice wants to connect to you!", "to accept: /ac alice", "to reject: /rc alice (the sender will NOT be notified)"), + timeoutMs = CONNECT_TIMEOUT_MS + ) + poll(what = "bob's contact connecting and not ready") { contactOf("bob").let { it.activeConn != null && !it.ready } } + hideKeyboard() + chat.info() + poll(what = "contact info of the not-ready bob without verification, preferences, servers, and with `Mute` disabled") { + exists(Ui.Info.DeleteContact) && exists(Ui.Info.Mute) && !isEnabled(Ui.Info.Mute) && + !exists(Ui.Info.VerifyCode) && !exists(Ui.Info.Preferences) && !exists(Ui.Info.ReceivingVia) && !exists(Ui.Info.SwitchAddress) + } + closeContactInfo() + bob sends "/ac alice" + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's contact ready") { contactOf("bob").ready } + chat.info() + poll(what = "verification, preferences, servers and an enabled `Mute` in the contact info of the ready bob") { + exists(Ui.Info.VerifyCode) && exists(Ui.Info.Preferences) && exists(Ui.Info.ReceivingVia) && exists(Ui.Info.SwitchAddress) && isEnabled(Ui.Info.Mute) + } + } + + @Test fun contactInfoAllowCallsAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.info() + val prefs = contactPrefs() + poll(what = "calls allowed by both") { seesText(CALLS_BOTH) && !seesText(CALLS_OFF) } + prefs.allow(ChatFeature.Calls, "NO") + poll(what = "calls prohibited after the change") { seesText(CALLS_OFF) } + prefs.save() + assertEquals("alice updated preferences for you:", bob.nextLine(CONNECT_TIMEOUT_MS)) + bob expects "Audio/video calls: off (you allow: default (yes), contact allows: no)" + poll(what = "alice's `no` for calls saved in bob's contact") { contactOf("bob").mergedPreferences.calls.userPreference.pref.allow == FeatureAllowed.NO } + prefs.backClosing() + closeContactInfo() + chat.info() + waitFor(Ui.Info.Call) + check(!seesText(ALLOW_CALLS_TEXT)) { "`Allow calls?` is shown before `call` is clicked" } + click(Ui.Info.Call) + poll(what = "the `Allow calls?` alert with `Allow`") { + seesText("Allow calls?") && seesText(ALLOW_CALLS_TEXT) && "Allow" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + assertEquals("alice updated preferences for you:", bob.nextLine(CONNECT_TIMEOUT_MS)) + bob expects "Audio/video calls: enabled (you allow: default (yes), contact allows: yes)" + poll(what = "calls enabled for alice after `Allow`") { contactOf("bob").mergedPreferences.calls.enabled.forUser } + bob sends "/set calls @alice no" + bob expects "you updated preferences for alice:" + bob expects "Audio/video calls: off (you allow: no, contact allows: yes)" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `no` for calls in alice's contact") { + contactOf("bob").mergedPreferences.calls.contactPreference.allow == FeatureAllowed.NO + } + closeContactInfo() + chat.info() + waitFor(Ui.Info.Call) + check(!seesText(CALLS_PROHIBITED_TEXT)) { "`Calls prohibited!` is shown before `call` is clicked" } + click(Ui.Info.Call) + poll(what = "the `Calls prohibited!` alert asking to have calls enabled, not `Allow calls?`") { + seesText("Calls prohibited!") && seesText(CALLS_PROHIBITED_TEXT) && !seesText("Allow calls?") + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the `Calls prohibited!` alert to close") { !seesText("Calls prohibited!") } + } + + @Test fun contactInfoSendReceiptsNo() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val direct = "@${bob.contactId("alice")}" + bob sends "@alice r0" + val chat = alice.chatList.open("bob") + chat.itemId("r0") + awaitReceipt(bob, direct, "r0") + chat.info() + poll(what = "`Send receipts` reading `default (yes)`") { "default (yes)" in textsOf(Ui.Info.SendReceipts) && contactOf("bob").chatSettings.sendRcpts == null } + click(Ui.Info.SendReceipts) + click(Ui.Info.SendReceipts.option(false)) + poll(what = "`Send receipts` reading `no`, saved for bob") { "no" in textsOf(Ui.Info.SendReceipts) && contactOf("bob").chatSettings.sendRcpts == false } + bob sends "@alice r1" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `r1` received while receipts are off") { loadedText("r1") } + click(Ui.Info.SendReceipts) + click(Ui.Info.SendReceipts.option("default")) + poll(what = "`Send receipts` back to `default (yes)`, saved for bob") { + "default (yes)" in textsOf(Ui.Info.SendReceipts) && contactOf("bob").chatSettings.sendRcpts == null + } + bob sends "@alice r2" + awaitReceipt(bob, direct, "r2") + assertEquals("r1 received while receipts were off", "sndSent", bob.itemStatus(direct, "r1")) + } + + @Test fun contactInfoIncognitoSection() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val incognito = connectIncognito(bob) + connect(cath) + check(incognito != "alice") { "bob sees alice's own name for an incognito connection" } + alice.chatList.row("bob") + alice.chatList.open("cath").info() + poll(what = "cath's contact info without `Your random profile`") { + infoNameText() == "cath" && exists(Ui.Info.DeleteContact) && !seesText(RANDOM_PROFILE) && !seesText(incognito) + } + closeContactInfo() + backToChatList() + alice.chatList.open("bob").info() + poll(what = "bob's contact info with `Your random profile` named `$incognito`, the name bob sees") { + infoNameText() == "bob" && seesText(RANDOM_PROFILE) && seesText(incognito) + } + } + + @Test fun contactInfoChatTtlEnable() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.info() + poll(what = "`Delete messages after` reading `default (never)`") { + "default (never)" in textsOf(Ui.Info.Ttl) && contactOf("bob").chatItemTTL == null + } + pickTtl(ChatItemTTL.Day) + poll(what = "the `Enable automatic message deletion?` alert with `Delete messages`") { + seesText(ENABLE_TTL) && "Delete messages" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the picker back at `default (never)` after `Cancel`") { !seesText(ENABLE_TTL) && "default (never)" in textsOf(Ui.Info.Ttl) } + check(contactOf("bob").chatItemTTL == null) { "`Cancel` set a message TTL for bob" } + pickTtl(ChatItemTTL.Day) + poll(what = "the `Enable automatic message deletion?` alert again") { seesText(ENABLE_TTL) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "bob's chat TTL of 1 day read back from the core, and `1 day` in the picker") { + contactOf("bob").chatItemTTL == 86400L && "1 day" in textsOf(Ui.Info.Ttl) + } + closeContactInfo() + chat.info() + poll(what = "`1 day` in reopened contact info") { "1 day" in textsOf(Ui.Info.Ttl) } + pickTtl(ChatItemTTL.None) + poll(what = "the `Disable automatic message deletion?` alert with `Disable delete messages`") { + seesText("Disable automatic message deletion?") && "Disable delete messages" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "bob's chat TTL set to never in the core and `never` in the picker") { + contactOf("bob").chatItemTTL == 0L && "never" in textsOf(Ui.Info.Ttl) + } + } + + private fun UiScope.pickTtl(ttl: ChatItemTTL) { + poll(what = "the previous message deletion change to finish before opening the picker") { UiIdle.inFlight().isEmpty() } + click(Ui.Info.Ttl) + click(Ui.Info.Ttl.option(ttl)) + } + + private fun UiScope.awaitReceipt(peer: CliPeer, chatRef: String, text: String) = + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "${peer.name} to get alice's receipt for `$text`") { peer.itemStatus(chatRef, text) == "sndRcvd" } + + private fun bobProfile() = + chatModel.chats.value.firstNotNullOfOrNull { (it.chatInfo as? ChatInfo.Direct)?.contact?.profile?.takeIf { p -> p.displayName == "bob" } } + + private fun CliPeer.updateProfile() { + this sends """/_profile 1 {"displayName":"bob","fullName":"$FULL_NAME","shortDescr":"$BIO"}""" + eventuallyMatching(Regex("user full name changed to $FULL_NAME \\(your 1 contacts are notified.*\\)"), CONNECT_TIMEOUT_MS) + } + + private companion object { + const val SHIELD = "�" + const val FULL_NAME = "Robert Brown" + const val BIO = "Plays the cello" + const val NETWORK_STATUS_TEXT = "You are connected to the server used to receive messages from this connection." + const val ABORT_SWITCH = "Abort changing address" + const val ADDRESS_FOOTER = "You can share this address with your contacts to let them connect with bob." + const val CALLS_BOTH = "Both you and your contact can make calls." + const val CALLS_OFF = "Audio/video calls are prohibited." + const val ALLOW_CALLS_TEXT = "You need to allow your contact to call to be able to call them." + const val CALLS_PROHIBITED_TEXT = "Please ask your contact to enable calls." + const val RANDOM_PROFILE = "Your random profile" + const val ENABLE_TTL = "Enable automatic message deletion?" + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactPreferencesTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactPreferencesTest.kt new file mode 100644 index 0000000000..d02c30fccc --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactPreferencesTest.kt @@ -0,0 +1,217 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.closeContactInfo +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.contactOf +import chat.simplex.app.ui.harness.contactPrefs +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.savedUserPrefs +import chat.simplex.app.ui.harness.userPrefs +import chat.simplex.common.model.ChatFeature +import chat.simplex.common.model.ContactUserPref +import chat.simplex.common.model.FeatureAllowed +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContactPreferencesTest : UiTest() { + @Test fun contactPrefsSaveNotifiesContactAndEnforces() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice one" + val chat = alice.chatList.open("bob") + val one = chat.itemId("one") + bob sends "\\@alice one" + bob.eventually("message marked deleted") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`one` only marked deleted while full deletion is off") { chat.item(one)?.meta?.itemDeleted != null && exists(Ui.Chat.Item(one)) } + chat.send("hi") + bob.expectsMessage("alice> hi", CONNECT_TIMEOUT_MS) + bob sends "+1 @alice hi" + bob expects "added 👍" + hideKeyboard() + chat.info() + var prefs = contactPrefs() + poll(what = "the default preferences: everything allowed except full deletion, with `Delete after`") { + seesText(TIMED_BOTH) && seesText(DELETE_OFF) && seesText(REACTIONS_BOTH) && seesText(VOICE_BOTH) && seesText(CALLS_BOTH) && + prefs.ttlShown() && "default (no)" in prefs.allowTexts(ChatFeature.FullDelete) + } + prefs.toggleTimed() + prefs.allow(ChatFeature.FullDelete, "ALWAYS") + prefs.allow(ChatFeature.Reactions, "NO") + prefs.allow(ChatFeature.Voice, "NO") + prefs.allow(ChatFeature.Calls, "NO") + poll(what = "every section showing the unsaved change") { + seesText(TIMED_OFF) && seesText(DELETE_BY_CONTACT) && seesText(REACTIONS_OFF) && seesText(VOICE_OFF) && seesText(CALLS_OFF) && + !seesText(TIMED_BOTH) && !seesText(VOICE_BOTH) && !prefs.ttlShown() && "always" in prefs.allowTexts(ChatFeature.FullDelete) + } + prefs.save() + assertEquals("alice updated preferences for you:", bob.nextLine(CONNECT_TIMEOUT_MS)) + bob expects "Disappearing messages: off (you allow: default (yes), contact allows: no)" + bob expects "Full deletion: enabled for you (you allow: default (no), contact allows: always)" + bob expects "Message reactions: off (you allow: default (yes), contact allows: no)" + bob expects "Voice messages: off (you allow: default (yes), contact allows: no)" + bob expects "Audio/video calls: off (you allow: default (yes), contact allows: no)" + poll(what = "the saved preferences in bob's contact") { contactOf("bob").mergedPreferences.calls.userPreference.pref.allow == FeatureAllowed.NO } + prefs.backClosing() + closeContactInfo() + bob sends "@alice two" + val two = chat.itemId("two") + bob sends "\\@alice two" + bob expects "message deleted" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`two` removed from alice's chat") { chat.item(two) == null && !exists(Ui.Chat.Item(two)) } + bob sends "+^ @alice hi" + bob expects "bad chat command: feature not allowed Message reactions" + click(described("Record voice message")) + poll(what = "the `Allow voice messages?` alert with `Allow`") { + seesText("Allow voice messages?") && seesText("You need to allow your contact to send voice messages to be able to send them.") && + "Allow" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + assertEquals("alice updated preferences for you:", bob.nextLine(CONNECT_TIMEOUT_MS)) + bob expects "Voice messages: enabled (you allow: default (yes), contact allows: yes)" + chat.info() + prefs = contactPrefs() + poll(what = "voice allowed and disappearing messages still off") { seesText(VOICE_BOTH) && seesText(TIMED_OFF) && !prefs.ttlShown() } + prefs.toggleTimed() + poll(what = "`Delete after` shown with `1 day`") { prefs.ttlShown() && "1 day" in prefs.ttlTexts() && seesText(TIMED_BOTH) } + prefs.save() + assertEquals("alice updated preferences for you:", bob.nextLine(CONNECT_TIMEOUT_MS)) + bob expects "Disappearing messages: enabled (you allow: yes (1 day), contact allows: yes (1 day))" + } + + @Test fun contactPrefsResetAndUnsavedAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.open("bob").info() + var prefs = contactPrefs() + poll(what = "voice messages allowed by both") { seesText(VOICE_BOTH) && !seesText(VOICE_OFF) } + bob sends "/set voice @alice no" + bob expects "you updated preferences for alice:" + bob expects "Voice messages: off (you allow: no, contact allows: yes)" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `no` shown under `Voice messages`") { + seesText(VOICE_OFF) && !seesText(VOICE_BOTH) && contactOf("bob").mergedPreferences.voice.contactPreference.allow == FeatureAllowed.NO + } + prefs.allow(ChatFeature.Reactions, "NO") + poll(what = "reactions prohibited after the change") { seesText(REACTIONS_OFF) && !seesText(REACTIONS_BOTH) } + prefs.reset() + poll(what = "reactions back to `default (yes)` after `Reset`") { + seesText(REACTIONS_BOTH) && !seesText(REACTIONS_OFF) && "default (yes)" in prefs.allowTexts(ChatFeature.Reactions) + } + prefs.backClosing() + prefs = contactPrefs() + prefs.allow(ChatFeature.Reactions, "NO") + poll(what = "reactions prohibited after the change") { seesText(REACTIONS_OFF) } + prefs.backExpectingAlert() + check("Save and notify contact" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && "Exit without saving" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared))) { + "the `Save preferences?` alert has no `Save and notify contact` / `Exit without saving`" + } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "contact preferences to close after `Exit without saving`") { !exists(Ui.ContactPrefs.Save) && !seesText("Save preferences?") } + check(contactOf("bob").mergedPreferences.reactions.userPreference is ContactUserPref.User) { "`Exit without saving` saved the reactions change" } + prefs = contactPrefs() + poll(what = "reactions still `default (yes)` after `Exit without saving`") { + seesText(REACTIONS_BOTH) && "default (yes)" in prefs.allowTexts(ChatFeature.Reactions) + } + prefs.allow(ChatFeature.Reactions, "NO") + poll(what = "reactions prohibited after the change") { seesText(REACTIONS_OFF) } + prefs.backExpectingAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "contact preferences to close with reactions saved off") { + !exists(Ui.ContactPrefs.Save) && contactOf("bob").mergedPreferences.reactions.userPreference.pref.allow == FeatureAllowed.NO + } + assertEquals("alice updated preferences for you:", bob.nextLine(CONNECT_TIMEOUT_MS)) + bob expects "Message reactions: off (you allow: default (yes), contact allows: no)" + } + + @Test fun userPrefsFullDeleteReactionsVoiceCalls() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + connect(cath) + alice.chatList.row("cath") + val prefs = userPrefs(alice) + poll(what = "the default preferences: full deletion only marked, the rest allowed if the contact allows") { + seesText(U_TIMED_YES, substring = true) && seesText(U_DELETE_NO) && seesText(U_REACTIONS_YES) && seesText(U_VOICE_YES) && seesText(U_CALLS_YES) + } + prefs.toggleTimed() + prefs.allow(ChatFeature.FullDelete, FeatureAllowed.ALWAYS) + prefs.allow(ChatFeature.Reactions, FeatureAllowed.NO) + prefs.allow(ChatFeature.Voice, FeatureAllowed.NO) + prefs.allow(ChatFeature.Calls, FeatureAllowed.NO) + poll(what = "every footer showing the unsaved change") { + seesText(U_TIMED_NO) && seesText(U_DELETE_ALWAYS) && seesText(U_REACTIONS_NO) && seesText(U_VOICE_NO) && seesText(U_CALLS_NO) && + !seesText(U_TIMED_YES, substring = true) && !seesText(U_DELETE_NO) && !seesText(U_CALLS_YES) + } + check(savedUserPrefs().calls.allow == FeatureAllowed.YES) { "the calls change was saved before `Save and notify contacts`" } + prefs.save() + for (peer in listOf(bob, cath)) { + assertEquals("alice updated preferences for you:", peer.nextLine(CONNECT_TIMEOUT_MS)) + peer expects "Disappearing messages: off (you allow: default (yes), contact allows: no)" + peer expects "Full deletion: enabled for you (you allow: default (no), contact allows: always)" + peer expects "Message reactions: off (you allow: default (yes), contact allows: no)" + peer expects "Voice messages: off (you allow: default (yes), contact allows: no)" + peer expects "Audio/video calls: off (you allow: default (yes), contact allows: no)" + } + poll(what = "the saved preferences in alice's profile") { + savedUserPrefs().let { it.calls.allow == FeatureAllowed.NO && it.fullDelete.allow == FeatureAllowed.ALWAYS && it.timedMessages.allow == FeatureAllowed.NO } + } + prefs.backClosing() + } + + @Test fun userPrefsResetAndUnsavedAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + var prefs = userPrefs(alice) + poll(what = "reactions allowed by default") { seesText(U_REACTIONS_YES) && "yes" in prefs.allowTexts(ChatFeature.Reactions) } + prefs.allow(ChatFeature.Reactions, FeatureAllowed.NO) + poll(what = "reactions prohibited after the change") { seesText(U_REACTIONS_NO) && "no" in prefs.allowTexts(ChatFeature.Reactions) } + prefs.reset() + poll(what = "reactions back to `yes` after `Reset`") { seesText(U_REACTIONS_YES) && "yes" in prefs.allowTexts(ChatFeature.Reactions) } + prefs.backClosing() + prefs = userPrefs(alice) + prefs.allow(ChatFeature.Reactions, FeatureAllowed.NO) + poll(what = "reactions prohibited after the change") { seesText(U_REACTIONS_NO) } + prefs.backExpectingAlert() + check("Save and notify contacts" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && "Exit without saving" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared))) { + "the `Save preferences?` alert has no `Save and notify contacts` / `Exit without saving`" + } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "your preferences to close after `Exit without saving`") { !exists(Ui.UserPrefs.Save) && !seesText("Save preferences?") } + check(savedUserPrefs().reactions.allow == FeatureAllowed.YES) { "`Exit without saving` saved the reactions change" } + prefs = userPrefs(alice) + poll(what = "reactions still `yes` after `Exit without saving`") { seesText(U_REACTIONS_YES) && "yes" in prefs.allowTexts(ChatFeature.Reactions) } + prefs.allow(ChatFeature.Reactions, FeatureAllowed.NO) + poll(what = "reactions prohibited after the change") { seesText(U_REACTIONS_NO) } + prefs.backExpectingAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "your preferences to close with reactions saved off") { + !exists(Ui.UserPrefs.Save) && savedUserPrefs().reactions.allow == FeatureAllowed.NO + } + assertEquals("alice updated preferences for you:", bob.nextLine(CONNECT_TIMEOUT_MS)) + bob expects "Message reactions: off (you allow: default (yes), contact allows: no)" + } + + private companion object { + const val TIMED_BOTH = "Both you and your contact can send disappearing messages." + const val TIMED_OFF = "Disappearing messages are prohibited in this chat." + const val DELETE_OFF = "Irreversible message deletion is prohibited in this chat." + const val DELETE_BY_CONTACT = "Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" + const val REACTIONS_BOTH = "Both you and your contact can add message reactions." + const val REACTIONS_OFF = "Message reactions are prohibited in this chat." + const val VOICE_BOTH = "Both you and your contact can send voice messages." + const val VOICE_OFF = "Voice messages are prohibited in this chat." + const val CALLS_BOTH = "Both you and your contact can make calls." + const val CALLS_OFF = "Audio/video calls are prohibited." + const val U_TIMED_YES = "Allow disappearing messages only if your contact allows them." + const val U_TIMED_NO = "Prohibit sending disappearing messages." + const val U_DELETE_NO = "Contacts can mark messages for deletion; you will be able to view them." + const val U_DELETE_ALWAYS = "Allow your contacts to irreversibly delete sent messages. (24 hours)" + const val U_REACTIONS_YES = "Allow message reactions only if your contact allows them." + const val U_REACTIONS_NO = "Prohibit message reactions." + const val U_VOICE_YES = "Allow voice messages only if your contact allows them." + const val U_VOICE_NO = "Prohibit sending voice messages." + const val U_CALLS_YES = "Allow calls only if your contact allows them." + const val U_CALLS_NO = "Prohibit audio/video calls." + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactsTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactsTest.kt index 4be3137261..7f512bab7b 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactsTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactsTest.kt @@ -3,9 +3,7 @@ package chat.simplex.app.ui.main import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS import chat.simplex.app.ui.harness.connect import chat.simplex.app.ui.harness.UiTest -import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui -import org.junit.Assert.assertTrue import org.junit.Test class ContactsTest : UiTest() { @@ -40,17 +38,6 @@ class ContactsTest : UiTest() { alice.chatList.gone("bob") } - @Test fun contactInfoSetLocalAlias() = uiTest { - val alice = app("alice"); val bob = cli("bob") - connect(bob) - alice.chatList.row("bob") - assertTrue("bob" in alice.chatList.rowTexts("bob")) - alice.chatList.open("bob").info() - typeInto(Ui.Info.Alias, "friend") - poll(what = "the row to read `friend`") { chatModel.chats.value.any { it.chatInfo.chatViewName == "friend" } } - poll(what = "the row text `friend` instead of `bob`") { alice.chatList.rowTexts("friend").let { "friend" in it && "bob" !in it } } - } - @Test fun editProfileDisplayName() = uiTest { val alice = app("alice"); val bob = cli("bob") connect(bob) diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/DeleteContactTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/DeleteContactTest.kt new file mode 100644 index 0000000000..1a5bbb46e9 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/DeleteContactTest.kt @@ -0,0 +1,224 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.archiveContact +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.contactOf +import chat.simplex.app.ui.harness.createAddress +import chat.simplex.app.ui.harness.enterLinkInSearch +import chat.simplex.app.ui.harness.openArchivedContact +import chat.simplex.app.ui.harness.openNewChatFromSearch +import chat.simplex.app.ui.harness.planConfirm +import chat.simplex.app.ui.harness.planDismiss +import chat.simplex.app.ui.harness.presetFilter +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.chatlist.PresetTagKind +import chat.simplex.common.views.helpers.ModalManager +import org.junit.Test + +class DeleteContactTest : UiTest() { + @Test fun deleteContactOnlyConversation() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + bob sends "@alice hello" + val bobId = alice.chatList.chatId("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `hello` on his row") { "hello" in alice.chatList.rowTexts("bob") } + alice.chatList.row("cath") + appPrefs.showDeleteConversationNotice.set(true) + poll(what = "the conversation notice stored as enabled") { storedSetting(CONVERSATION_NOTICE) == "true" } + alice.chatList.rowMenu("bob", "delete") + poll(what = "`Delete contact?` with `Only delete conversation`, `Delete contact` and `Cancel`") { + seesText("Delete contact?") && "Only delete conversation" in textsOf(ONLY_CONVERSATION) && + "Delete contact" in textsOf(DELETE_CONTACT) && "Cancel" in textsOf(CANCEL) + } + click(CANCEL) + poll(what = "the alert closed with bob's row and `hello` kept") { + !exists(ONLY_CONVERSATION) && "hello" in alice.chatList.rowTexts("bob") && !contactOf("bob").chatDeleted + } + alice.chatList.rowMenu("bob", "delete") + click(ONLY_CONVERSATION) + poll(what = "the `Conversation deleted!` notice with `Don't show again`") { + seesText("Conversation deleted!") && seesText("You can send messages to bob from Archived contacts.") && + "Don't show again" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the notice closed and turned off on disk") { + !seesText("Conversation deleted!") && !appPrefs.showDeleteConversationNotice.get() && storedSetting(CONVERSATION_NOTICE) == "false" + } + poll(what = "bob's row hidden with his contact archived, and cath still listed") { + contactOf("bob").chatDeleted && !exists(Ui.ChatList.Row(bobId)) && alice.chatList.shown("cath") + } + presetFilter(PresetTagKind.CONTACTS, "Contacts") + poll(what = "only cath under `Contacts`") { + alice.chatList.shown("cath") && !exists(Ui.ChatList.Row(bobId)) && !alice.chatList.shown("Private notes") + } + bob sends "@alice hi" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob back under `Contacts` with `hi` and without `hello`") { + !contactOf("bob").chatDeleted && exists(Ui.ChatList.Row(bobId)) && alice.chatList.rowTexts("bob").let { "hi" in it && "hello" !in it } + } + val chat = alice.chatList.open("bob") + chat.itemId("hi") + check("hello" !in chat.itemTexts()) { "the deleted conversation came back with bob's new message" } + chat.send("back") + bob.expectsMessage("alice> back", CONNECT_TIMEOUT_MS) + } + + @Test fun deleteContactWithoutNotification() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + alice.chatList.row("cath") + val bobId = alice.chatList.chatId("bob") + alice.chatList.open("bob").info() + click(Ui.Info.DeleteContact) + click(DELETE_CONTACT) + poll(what = "the second `Delete contact?` alert with `Keep conversation` and both delete options") { + seesText("bob\n\nContact will be deleted - this cannot be undone!") && exists(KEEP_CONVERSATION) && + "Delete without notification" in textsOf(WITHOUT_NOTIFICATION) && "Delete and notify contact" in textsOf(AND_NOTIFY) + } + click(WITHOUT_NOTIFICATION) + poll(what = "contact info and bob's chat closed") { chatModel.chatId.value == null && !ModalManager.end.hasModalsOpen() && !exists(Ui.Info.DeleteContact) } + alice.chatList.gone("bob") + check(!exists(Ui.ChatList.Row(bobId)) && alice.chatList.shown("cath")) { "bob's row is listed or cath's row is gone after deleting bob" } + alice.chatList.rowMenu("cath", "delete") + click(DELETE_CONTACT) + click(AND_NOTIFY) + cath.eventually("alice deleted contact with you", CONNECT_TIMEOUT_MS) + alice.chatList.gone("cath") + bob sends "/u" + bob expects "user profile: bob" + } + + @Test fun deleteContactKeepConversation() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hello" + val bobId = alice.chatList.chatId("bob") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `hello` on his row") { "hello" in alice.chatList.rowTexts("bob") } + appPrefs.showDeleteContactNotice.set(true) + alice.chatList.rowMenu("bob", "delete") + click(DELETE_CONTACT) + click(KEEP_CONVERSATION) + click(AND_NOTIFY) + poll(what = "the `Contact deleted!` notice with `OK` and `Don't show again`") { + seesText("Contact deleted!") && seesText("You can still view conversation with bob in the list of chats.") && + "OK" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && "Don't show again" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } + bob.eventually("alice deleted contact with you", CONNECT_TIMEOUT_MS) + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the notice closed and still enabled on disk") { + !seesText("Contact deleted!") && appPrefs.showDeleteContactNotice.get() && storedSetting(CONTACT_NOTICE) == "true" + } + poll(what = "bob's row kept with `hello` and his contact deleted") { + !contactOf("bob").active && exists(Ui.ChatList.Row(bobId)) && "hello" in alice.chatList.rowTexts("bob") + } + val chat = alice.chatList.open("bob") + chat.itemId("hello") + poll(what = "`contact deleted` in place of the message field") { seesText("contact deleted") } + } + + @Test fun contactDeletedByPeerShowsDeleted() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hello" + val chat = alice.chatList.open("bob") + chat.itemId("hello") + check(contactOf("bob").active && !seesText("contact deleted")) { "bob's contact is inactive before he deletes it" } + bob sends "/d alice" + bob expects "alice: contact is deleted" + chat.itemId("bob's `deleted contact` event") { it.content.text == "deleted contact" } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's contact inactive and `contact deleted` in place of the message field") { + !contactOf("bob").active && seesText("contact deleted") + } + } + + @Test fun deleteArchivedContactConfirm() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + archiveContact(alice, "bob") + archiveContact(alice, "cath") + openArchivedContact(alice, "cath").contactInfo() + click(Ui.Info.DeleteContact) + awaitConfirmArchivedDeletion("cath") + click(CANCEL) + poll(what = "the alert closed with cath still archived") { !seesText(CONFIRM_DELETION) && contactOf("cath").chatDeleted } + click(Ui.Info.DeleteContact) + awaitConfirmArchivedDeletion("cath") + click(WITHOUT_NOTIFICATION) + alice.chatList.gone("cath") + backToChatList() + openArchivedContact(alice, "bob").contactInfo() + click(Ui.Info.DeleteContact) + awaitConfirmArchivedDeletion("bob") + click(AND_NOTIFY) + bob.eventually("alice deleted contact with you", CONNECT_TIMEOUT_MS) + alice.chatList.gone("bob") + cath sends "/u" + cath expects "user profile: cath" + } + + @Test fun deleteNotReadyContactConfirm() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = bob.createAddress(autoAccept = false) + alice.chatList.row("Private notes") + val chat = openNewChatFromSearch(address, "bob") + click(Ui.Chat.Connect) + poll(what = "`Send contact request?` with `Send request without message`") { + seesText("Send contact request?") && "Send request without message" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.eventuallyAll( + listOf("alice wants to connect to you!", "to accept: /ac alice", "to reject: /rc alice (the sender will NOT be notified)"), + timeoutMs = CONNECT_TIMEOUT_MS + ) + poll(what = "bob's contact connecting and not ready") { contactOf("bob").let { it.activeConn != null && !it.ready } } + val bobId = alice.chatList.chatId("bob") + hideKeyboard() + chat.info() + click(Ui.Info.DeleteContact) + awaitConfirmNotReadyDeletion() + click(CANCEL) + poll(what = "the alert closed with bob kept") { !seesText(CONFIRM_DELETION) && chatModel.chats.value.any { it.id == bobId } } + click(Ui.Info.DeleteContact) + awaitConfirmNotReadyDeletion() + click(CONFIRM) + alice.chatList.gone("bob") + poll(what = "contact info and bob's chat closed") { chatModel.chatId.value == null && !ModalManager.end.hasModalsOpen() } + backToChatList() + enterLinkInSearch(address) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the plan for bob's address offering `Open new chat` again, as the contact is gone from the core") { + exists(planConfirm) && "Open new chat" in textsOf(planConfirm) && seesText("bob") + } + click(planDismiss) + } + + private fun UiScope.awaitConfirmArchivedDeletion(name: String) = + poll(what = "`Confirm contact deletion?` for the archived $name with both delete options and `Cancel`") { + seesText(CONFIRM_DELETION) && seesText("$name\n\n$CANNOT_UNDO") && "Delete and notify contact" in textsOf(AND_NOTIFY) && + "Delete without notification" in textsOf(WITHOUT_NOTIFICATION) && "Cancel" in textsOf(CANCEL) && !seesText("Delete contact?") + } + + private fun UiScope.awaitConfirmNotReadyDeletion() = + poll(what = "`Confirm contact deletion?` for the not-ready bob with only `Confirm` and `Cancel`") { + seesText(CONFIRM_DELETION) && seesText("bob\n\n$CANNOT_UNDO") && "Confirm" in textsOf(CONFIRM) && "Cancel" in textsOf(CANCEL) && + !exists(AND_NOTIFY) && !exists(WITHOUT_NOTIFICATION) + } + + private companion object { + val ONLY_CONVERSATION = Ui.Alert.Option(Ui.AlertSource.Shared, "onlyConversation") + val DELETE_CONTACT = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteContact") + val CANCEL = Ui.Alert.Option(Ui.AlertSource.Shared, "cancel") + val KEEP_CONVERSATION = Ui.Alert.Option(Ui.AlertSource.Shared, "keepConversation") + val WITHOUT_NOTIFICATION = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteWithoutNotification") + val AND_NOTIFY = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteAndNotify") + val CONFIRM = Ui.Alert.Option(Ui.AlertSource.Shared, "confirm") + const val CONFIRM_DELETION = "Confirm contact deletion?" + const val CANNOT_UNDO = "Contact will be deleted - this cannot be undone!" + const val CONVERSATION_NOTICE = "showDeleteConversationNotice" + const val CONTACT_NOTICE = "showDeleteContactNotice" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/NewChatSheetTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/NewChatSheetTest.kt new file mode 100644 index 0000000000..f73da1a75f --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/NewChatSheetTest.kt @@ -0,0 +1,190 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.contactOf +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.dismissNewChatSheet +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.labelled +import chat.simplex.app.ui.harness.openContextMenu +import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class NewChatSheetTest : UiTest() { + @Test fun newChatSheetShowsActions() = uiTest { + val alice = app("alice") + alice.newChat() + awaitSheet() + check( + "Create 1-time link" in textsOf(Ui.NewChat.CreateLink) && + (if (appPlatform.isAndroid) "Paste link / Scan" else "Paste link") in textsOf(Ui.NewChat.PasteLink) && + "Create group" in textsOf(Ui.NewChat.CreateGroup) && + "Create public channel" in textsOf(Ui.NewChat.CreateChannel) + ) { "the New chat actions are not all shown with their labels" } + check(!exists(Ui.NewGroup.Name) && !seesText("Channel name")) { "a creation screen is shown before its action is clicked" } + + click(Ui.NewChat.CreateGroup) + poll(what = "the `Create secret group` screen in place of the sheet") { + exists(Ui.NewGroup.Name) && seesText("Create secret group") && !exists(Ui.NewChat.CreateLink) && !seesText("Channel name") + } + leave(what = "the group screen") { !exists(Ui.NewGroup.Name) } + awaitSheet() + + click(Ui.NewChat.CreateChannel) + poll(what = "the `Create public channel` screen in place of the sheet") { + seesText("Channel name") && seesText("Configure relays") && !exists(Ui.NewChat.CreateLink) && !exists(Ui.NewGroup.Name) + } + leave(what = "the channel screen") { !seesText("Channel name") } + awaitSheet() + } + + @Test fun newChatSheetSearchFiltersContacts() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + connect(cath) + val bobId = alice.chatList.chatId("bob"); val cathId = alice.chatList.chatId("cath") + alice.newChat() + poll(what = "`Your contacts` listing bob and cath") { + seesText("Your contacts") && exists(Ui.NewChat.Contact(bobId)) && exists(Ui.NewChat.Contact(cathId)) + } + typeInto(Ui.NewChat.Search, "CA") + poll(what = "only cath listed for `CA`") { + exists(Ui.NewChat.Contact(cathId)) && !exists(Ui.NewChat.Contact(bobId)) && !seesText("Your contacts") && !seesText("No filtered contacts") + } + replaceText(Ui.NewChat.Search, "zzz") + poll(what = "`No filtered contacts` and no contact listed for `zzz`") { + seesText("No filtered contacts") && !exists(Ui.NewChat.Contact(cathId)) && !exists(Ui.NewChat.Contact(bobId)) + } + replaceText(Ui.NewChat.Search, "ca") + poll(what = "only cath listed for `ca`") { exists(Ui.NewChat.Contact(cathId)) && !exists(Ui.NewChat.Contact(bobId)) } + hideKeyboard() + click(Ui.NewChat.Contact(cathId)) + poll(what = "cath's chat open and the New chat sheet closed") { + chatModel.chatId.value == cathId && !chatModel.newChatSheetVisible.value && !exists(Ui.NewChat.Search) + } + ChatScreen(this).send("found you") + cath.expectsMessage("alice> found you", CONNECT_TIMEOUT_MS) + } + + @Test fun archivedContactsSendMessageRestores() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + connect(cath) + val bobId = alice.chatList.chatId("bob"); val cathId = alice.chatList.chatId("cath") + alice.chatList.row("cath") + alice.newChat() + poll(what = "bob and cath under `Your contacts` and no `Archived contacts` row") { + exists(Ui.NewChat.Contact(bobId)) && exists(Ui.NewChat.Contact(cathId)) && exists(Ui.NewChat.CreateLink) && !exists(Ui.NewChat.Archived) + } + dismissNewChatSheet() + alice.chatList.rowMenu("bob", "delete") + click(ONLY_CONVERSATION) + poll(what = "bob archived and gone from the chat list") { contactOf("bob").chatDeleted && !exists(Ui.ChatList.Row(bobId)) } + + alice.newChat() + poll(what = "the `Archived contacts` row, with cath but not bob under `Your contacts`") { + labelled(Ui.NewChat.Archived, "Archived contacts") && exists(Ui.NewChat.Contact(cathId)) && !exists(Ui.NewChat.Contact(bobId)) + } + typeInto(Ui.NewChat.Search, "bo") + poll(what = "the archived bob found by `bo`, cath not") { + exists(Ui.NewChat.Contact(bobId)) && !exists(Ui.NewChat.Contact(cathId)) && !exists(Ui.NewChat.Archived) + } + hideKeyboard() + click(described("Close button")) + poll(what = "the search cleared, bob hidden again behind `Archived contacts`") { + editableText(Ui.NewChat.Search).isNullOrEmpty() && exists(Ui.NewChat.Archived) && exists(Ui.NewChat.Contact(cathId)) && !exists(Ui.NewChat.Contact(bobId)) + } + click(Ui.NewChat.Archived) + poll(what = "the `Archived contacts` list with bob only, in place of the sheet") { + exists(Ui.NewChat.Contact(bobId)) && !exists(Ui.NewChat.Contact(cathId)) && !exists(Ui.NewChat.CreateLink) + } + click(Ui.NewChat.Contact(bobId)) + poll(what = "bob's chat open with the archived list and the sheet closed") { + chatModel.chatId.value == bobId && !exists(Ui.NewChat.Contact(bobId)) && !exists(Ui.NewChat.Search) + } + ChatScreen(this).send("hi") + bob.expectsMessage("alice> hi", CONNECT_TIMEOUT_MS) + backToChatList() + poll(what = "bob back in the chat list with `hi`") { + !contactOf("bob").chatDeleted && exists(Ui.ChatList.Row(bobId)) && "hi" in alice.chatList.rowTexts("bob") + } + alice.newChat() + poll(what = "bob under `Your contacts` and no `Archived contacts` row") { + exists(Ui.NewChat.Contact(bobId)) && exists(Ui.NewChat.Contact(cathId)) && exists(Ui.NewChat.CreateLink) && !exists(Ui.NewChat.Archived) + } + } + + @Test fun newChatSheetFavoritesFilterToggle() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + connect(cath) + val bobId = alice.chatList.chatId("bob"); val cathId = alice.chatList.chatId("cath") + alice.chatList.row("cath") + alice.chatList.rowMenu("bob", "favorite", "Favorite") + poll(what = "bob favourite and cath not") { + alice.chatList.chat("bob").chatInfo.chatSettings?.favorite == true && alice.chatList.chat("cath").chatInfo.chatSettings?.favorite == false + } + alice.newChat() + poll(what = "bob and cath listed with the filter off") { exists(Ui.NewChat.Contact(bobId)) && exists(Ui.NewChat.Contact(cathId)) && exists(Ui.NewChat.Favorites) } + check(storedSetting(FAVORITES_FILTER) != "true") { "the favourites filter is stored as on before it is toggled" } + click(Ui.NewChat.Favorites) + poll(what = "only the favourite bob listed and the filter stored as on") { + exists(Ui.NewChat.Contact(bobId)) && !exists(Ui.NewChat.Contact(cathId)) && storedSetting(FAVORITES_FILTER) == "true" + } + click(Ui.NewChat.Favorites) + poll(what = "bob and cath listed again and the filter stored as off") { + exists(Ui.NewChat.Contact(bobId)) && exists(Ui.NewChat.Contact(cathId)) && storedSetting(FAVORITES_FILTER) == "false" + } + } + + @Test fun newChatSheetContactWithRequestOpensChat() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + val address = alice.userPicker().createAddress() + connect(bob) + cath sends "/c $address" + cath expects "connection request sent!" + alice.chatList.request("cath") + val bobId = alice.chatList.chatId("bob"); val cathId = alice.chatList.chatId("cath") + alice.newChat() + poll(what = "bob and cath's request under `Your contacts`") { exists(Ui.NewChat.Contact(bobId)) && exists(Ui.NewChat.Contact(cathId)) } + + openContextMenu(Ui.NewChat.Contact(bobId)) + click(Ui.NewChat.ContactMenu(bobId, "delete")) + click(DELETE_CONTACT) + click(AND_NOTIFY) + bob.eventually("alice deleted contact with you", CONNECT_TIMEOUT_MS) + poll(what = "bob gone from the sheet, cath still listed") { + chatModel.chats.value.none { it.id == bobId } && !exists(Ui.NewChat.Contact(bobId)) && exists(Ui.NewChat.Contact(cathId)) + } + + click(Ui.NewChat.Contact(cathId)) + poll(what = "cath's chat open with the `Reject` / `Accept` bar and the sheet closed") { + chatModel.chatId.value == cathId && exists(Ui.Chat.RequestAccept) && exists(Ui.Chat.RequestReject) && !exists(Ui.Chat.Message) && !exists(Ui.NewChat.Search) + } + } + + private fun UiScope.awaitSheet() = + poll(what = "the New chat sheet with its actions") { + exists(Ui.NewChat.CreateLink) && exists(Ui.NewChat.CreateGroup) && exists(Ui.NewChat.CreateChannel) + } + + private fun UiScope.leave(what: String, closed: () -> Boolean) { + hideKeyboard() + goBack() + poll(what = "$what to close", cond = closed) + } + + private companion object { + val ONLY_CONVERSATION = Ui.Alert.Option(Ui.AlertSource.Shared, "onlyConversation") + val DELETE_CONTACT = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteContact") + val AND_NOTIFY = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteAndNotify") + const val FAVORITES_FILTER = "ShowUnreadAndFavorites" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/OneTimeLinkCompatTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/OneTimeLinkCompatTest.kt index c53a1154ad..dde6a6b512 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/OneTimeLinkCompatTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/OneTimeLinkCompatTest.kt @@ -2,6 +2,8 @@ package chat.simplex.app.ui.main import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS import chat.simplex.app.ui.harness.UiTest +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.Ui import org.junit.Test class OneTimeLinkCompatTest : UiTest() { @@ -12,4 +14,30 @@ class OneTimeLinkCompatTest : UiTest() { bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) alice.chatList.row("bob") } + + @Test fun oneTimeLinkCliConnectsViaFullLink() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val short = alice.newChat().createOneTimeLink() + poll(what = "the 1-time link without the `Full link` toggle while developer tools are off") { + seesText("OR SHOW THIS CODE") && !exists(Ui.Link.ShortLinkToggle) + } + check(FULL_INVITATION !in short) { "the 1-time link shown by default is the full link `$short`" } + enableDeveloperToolsViaApi() + poll(what = "the `Full link` toggle with developer tools on") { exists(Ui.Link.ShortLinkToggle) && "Full link" in textsOf(Ui.Link.ShortLinkToggle) } + click(Ui.Link.ShortLinkToggle) + var full: String? = null + poll(what = "the full link in place of the short one, and the toggle reading `Short link`") { + full = editableText(Ui.Link.Text)?.takeIf { FULL_INVITATION in it } + full != null && "Short link" in textsOf(Ui.Link.ShortLinkToggle) + } + bob sends "/c $full"; bob expects "confirmation sent!" + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + alice.chatList.row("bob") + } + + private fun enableDeveloperToolsViaApi() = appPrefs.developerTools.set(true) + + private companion object { + const val FULL_INVITATION = "/invitation#/?v=" + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/OneTimeLinkTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/OneTimeLinkTest.kt index 6fe192aa80..e6b66a87c2 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/OneTimeLinkTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/OneTimeLinkTest.kt @@ -1,14 +1,29 @@ package chat.simplex.app.ui.main import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.NEW_RANDOM_PROFILE import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.awaitInvitationGone +import chat.simplex.app.ui.harness.awaitInviteClosed +import chat.simplex.app.ui.harness.chooseLinkProfile +import chat.simplex.app.ui.harness.closeNewChatSheet +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.hideUserViaApi +import chat.simplex.app.ui.harness.leaveInviteExpectingKeepAlert +import chat.simplex.app.ui.harness.pendingConnectionIds +import chat.simplex.app.ui.harness.userIdOf import chat.simplex.common.model.ChatInfo import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Test +private const val INCOGNITO_PREF = "Incognito" +private const val HIDDEN_PASSWORD = "hide-me-42" +private val PROFILE_SEARCH = Ui.Link.ProfileBar.part("search") + class OneTimeLinkTest : UiTest() { @Test fun oneTimeLinkSwitchToIncognito() = uiTest { val alice = app("alice"); val bob = cli("bob") @@ -24,4 +39,110 @@ class OneTimeLinkTest : UiTest() { chatModel.chats.value.any { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.displayName == "bob" && c.contactConnIncognito } == true } } } + + @Test fun createOneTimeLinkShowsLinkAndQr() = uiTest { + val alice = app("alice") + val sheet = alice.newChat() + poll(what = "the New chat sheet without a link or a QR code") { exists(Ui.NewChat.CreateLink) && !exists(Ui.Link.Text) && !seesDescription("QR Code") } + sheet.createOneTimeLink() + poll(what = "the link section, the QR code and alice in `Share profile`") { + seesText("Share this 1-time invite link") && seesText("OR SHOW THIS CODE") && seesDescription("QR Code") && "alice" in textsOf(Ui.Link.ShareProfile) + } + } + + @Test fun oneTimeLinkKeepUnusedAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val deleted = alice.newChat().createOneTimeLink() + val deletedId = pendingConnectionIds().single() + leaveInviteExpectingKeepAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + closeNewChatSheet() + poll(what = "the deleted link's pending connection gone") { deletedId !in pendingConnectionIds() && !exists(Ui.ChatList.Row(deletedId)) } + + val shared = alice.newChat().createOneTimeLink() + click(Ui.Link.Text) + poll(what = "the link shared") { clipboardText() == shared } + goBack() + awaitInviteClosed() + check(!AlertManager.shared.hasAlertsShown() && !seesText("Keep unused invitation?")) { "`Keep unused invitation?` shown for a shared link" } + closeNewChatSheet() + poll(what = "the shared link's pending connection listed") { pendingConnectionIds().size == 1 } + + val kept = alice.newChat().createOneTimeLink() + leaveInviteExpectingKeepAlert() + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + closeNewChatSheet() + poll(what = "the kept link's pending connection listed next to the shared one") { pendingConnectionIds().size == 2 } + + bob sends "/c $kept"; bob expects "confirmation sent!" + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + alice.chatList.row("bob") + awaitInvitationGone(bob, deleted) + } + + @Test fun oneTimeLinkChangeToOtherProfile() = uiTest { + val alice = app("alice"); val bob = cli("bob") + alice.userPicker().profiles().apply { add("work"); close() } + val workId = userIdOf("work") + alice.userPicker().switchTo("alice") + val first = alice.newChat().createOneTimeLink() + poll(what = "alice in `Share profile`") { "alice" in textsOf(Ui.Link.ShareProfile) } + click(Ui.Link.ShareProfile) + val second = chooseLinkProfile(workId, first) + poll(what = "work in `Share profile`") { "work" in textsOf(Ui.Link.ShareProfile) && "alice" !in textsOf(Ui.Link.ShareProfile) } + bob sends "/c $second"; bob expects "confirmation sent!" + bob.eventually("work: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob in work's chats") { + chatModel.currentUser.value?.userId == workId && chatModel.chats.value.any { it.chatInfo is ChatInfo.Direct && it.chatInfo.chatViewName == "bob" } + } + awaitInvitationGone(bob, first) + } + + @Test fun oneTimeLinkChangeToHiddenProfileViaPassword() = uiTest { + val alice = app("alice"); val bob = cli("bob") + alice.userPicker().profiles().apply { add("work"); close() } + val workId = userIdOf("work"); val aliceId = userIdOf("alice") + alice.userPicker().switchTo("alice") + hideUserViaApi(workId, HIDDEN_PASSWORD) + val first = alice.newChat().createOneTimeLink() + click(Ui.Link.ShareProfile) + poll(what = "alice listed in `Select chat profile` and the hidden work not") { exists(Ui.Link.Profile(aliceId)) && !exists(Ui.Link.Profile(workId)) } + typeInto(PROFILE_SEARCH, "work") + poll(what = "no profile listed for the hidden profile's name") { + editableText(PROFILE_SEARCH) == "work" && exists(Ui.Link.ProfileIncognito) && !exists(Ui.Link.Profile(workId)) && !exists(Ui.Link.Profile(aliceId)) + } + replaceText(PROFILE_SEARCH, HIDDEN_PASSWORD) + poll(what = "work listed for its password") { exists(Ui.Link.Profile(workId)) } + hideKeyboard() + val link = chooseLinkProfile(workId, first) + poll(what = "work in `Share profile`") { "work" in textsOf(Ui.Link.ShareProfile) } + bob sends "/c $link"; bob expects "confirmation sent!" + bob.eventually("work: contact is connected", CONNECT_TIMEOUT_MS) + } + + @Test fun oneTimeLinkSwitchBackFromIncognito() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val aliceId = userIdOf("alice") + check(storedSetting(INCOGNITO_PREF) != "true") { "incognito is stored as the default before the test chooses it" } + val sheet = alice.newChat() + sheet.createOneTimeLink() + click(Ui.Link.ShareProfile) + click(Ui.Link.ProfileIncognito) + poll(what = "the incognito footer and incognito stored as the default") { seesText(NEW_RANDOM_PROFILE) && storedSetting(INCOGNITO_PREF) == "true" } + goBack() + awaitInviteClosed() + + val incognitoLink = sheet.createOneTimeLink() + poll(what = "the new link incognito by default") { seesText(NEW_RANDOM_PROFILE) && "Incognito" in textsOf(Ui.Link.ShareProfile) } + click(Ui.Link.ShareProfile) + val link = chooseLinkProfile(aliceId, incognitoLink) + poll(what = "alice in `Share profile`, no footer and incognito no longer the default") { + "alice" in textsOf(Ui.Link.ShareProfile) && !seesText(NEW_RANDOM_PROFILE) && storedSetting(INCOGNITO_PREF) == "false" + } + bob sends "/c $link"; bob expects "confirmation sent!" + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "a contact bob that is not incognito") { + chatModel.chats.value.any { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.displayName == "bob" && !c.contactConnIncognito } == true } + } + } } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/PendingConnectionTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/PendingConnectionTest.kt new file mode 100644 index 0000000000..d228a8942f --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/PendingConnectionTest.kt @@ -0,0 +1,156 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.NEW_RANDOM_PROFILE +import chat.simplex.app.ui.harness.PeerFlags +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.closePendingConnectionInfo +import chat.simplex.app.ui.harness.connectionRequestSentToGroup +import chat.simplex.app.ui.harness.createFullGroupLink +import chat.simplex.app.ui.harness.createFullOneTimeLink +import chat.simplex.app.ui.harness.described +import chat.simplex.app.ui.harness.enterLinkInSearch +import chat.simplex.app.ui.harness.joinOneTimeLinkInSearch +import chat.simplex.app.ui.harness.keepIncognitoOneTimeLink +import chat.simplex.app.ui.harness.keepOneTimeLink +import chat.simplex.app.ui.harness.openChatById +import chat.simplex.app.ui.harness.pendingConnectionIds +import chat.simplex.app.ui.harness.planOption +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class PendingConnectionTest : UiTest() { + @Test fun pendingInfoInvitedSharesWorkingLink() = uiTest { + val alice = app("alice"); val bob = cli("bob") + keepOneTimeLink(alice) + alice.chatList.open(INVITED) + poll(what = "`You invited a contact` with the one-time link section and `Share 1-time link`") { + seesText("You invited a contact") && seesText("One-time invitation link") && seesText(CONTACT_ONLINE) && exists(SHARE) && + !seesText("You accepted connection") + } + setClipboardText(null) + check(clipboardText() == null) { "the clipboard is not empty before sharing the link" } + click(SHARE) + var link: String? = null + poll(what = "the one-time link shared") { link = clipboardText()?.takeIf { it.isNotBlank() }; link != null } + bob sends "/c $link" + bob expects "confirmation sent!" + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob connected in place of the pending connection") { + chatModel.chats.value.any { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.displayName == "bob" && c.sndReady } == true } && + chatModel.chats.value.none { it.chatInfo is ChatInfo.ContactConnection } + } + } + + @Test fun pendingInfoDelete() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = keepOneTimeLink(alice) + alice.chatList.open(INVITED) + poll(what = "`You invited a contact` with `Delete`") { seesText("You invited a contact") && exists(DELETE) } + click(DELETE) + poll(what = "the `Delete pending connection?` alert for the invitation with `Delete`") { + seesText("Delete pending connection?") && seesText("$INVITED\n\nThe contact you shared this link with will NOT be able to connect!") && + "Delete" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + alice.chatList.gone(INVITED) + poll(what = "the pending connection info closed") { chatModel.chatId.value == null && !seesText("You invited a contact") } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the deleted link to fail its plan on the server") { + bob sends "/_connect plan 1 $link" + bob.eventuallyMatching(PLAN_RESULT).value.startsWith("error") + } + bob sends "/c $link" + bob.eventuallyMatching(AUTH_FAILED) + } + + @KnownAppIssue("15.5 item 18", "waiting for the pending row named `accepted invitation` with `via one-time link`") + @Test fun pendingInfoAcceptedShowsNoLink() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(fullLinks = true)) + val full = bob.createFullOneTimeLink() + bob.stop() + alice.chatList.row("Private notes") + joinOneTimeLinkInSearch(full) + val pending = chatModel.chats.value.first { it.chatInfo is ChatInfo.ContactConnection }.id + poll(what = "the pending row named `$ACCEPTED` with `via one-time link`") { + textsOf(Ui.ChatList.Row(pending)).let { ACCEPTED in it && "via one-time link" in it } + } + alice.chatList.open(ACCEPTED) + poll(what = "`You accepted connection` with the name field and `Learn more`, without the one-time link or `Share 1-time link`") { + seesText("You accepted connection") && seesText(CONTACT_ONLINE) && exists(Ui.Info.Alias) && exists(described("Learn more")) && + !seesText("You invited a contact") && !seesText("One-time invitation link") && !exists(SHARE) + } + typeInto(Ui.Info.Alias, "dentist") + poll(what = "the alias `dentist` saved for the pending connection") { + chatModel.chats.value.any { (it.chatInfo as? ChatInfo.ContactConnection)?.contactConnection?.localAlias == "dentist" } + } + bob.start() + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob connected under the alias `dentist` and no pending connection left") { + chatModel.chats.value.any { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.profile.displayName == "bob" && c.localAlias == "dentist" && c.sndReady } == true } && + chatModel.chats.value.none { it.chatInfo is ChatInfo.ContactConnection } + } + alice.chatList.row("dentist") + } + + @Test fun pendingInfoIncognitoShown() = uiTest { + val alice = app("alice") + keepOneTimeLink(alice) + val plain = pendingConnectionIds().single() + keepIncognitoOneTimeLink(alice) + val incognito = pendingConnectionIds().single { it != plain } + openChatById(plain) + poll(what = "the plain invitation's info sharing alice's profile, without `Incognito`") { + seesText("You invited a contact") && seesText(OWN_PROFILE) && !seesText("Incognito") && !seesText(NEW_RANDOM_PROFILE) + } + closePendingConnectionInfo() + openChatById(incognito) + poll(what = "the incognito invitation's info with `Incognito` and the random profile footer") { + seesText("You invited a contact") && seesText("Incognito") && seesText(NEW_RANDOM_PROFILE) && !seesText(OWN_PROFILE) + } + check(!seesText(SCAN_TO_CONNECT, substring = true)) { "the `Learn more` text is shown before `Learn more` is clicked" } + click(described("Learn more")) + poll(what = "the `One-time invitation link` page explaining how to connect") { seesText(SCAN_TO_CONNECT, substring = true) } + } + + @Test fun pendingInfoGroupLinkHidesAlias() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(fullLinks = true)) + val full = bob.createFullGroupLink("team") + bob.stop() + keepOneTimeLink(alice) + val invitation = pendingConnectionIds().single() + enterLinkInSearch(full) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Join group?` alert with `Use current profile`") { + seesText("Join group?") && exists(planOption("useCurrentProfile")) + } + click(planOption("useCurrentProfile")) + connectionRequestSentToGroup() + var joining: String? = null + poll(what = "a pending connection for the group link next to the invitation") { + joining = pendingConnectionIds().singleOrNull { it != invitation } + joining != null + } + openChatById(invitation) + poll(what = "the invitation's info with the name field") { seesText("You invited a contact") && exists(Ui.Info.Alias) } + closePendingConnectionInfo() + openChatById(joining!!) + poll(what = "the group link connection's info waiting for the host, without the name field") { + seesText(GROUP_HOST_ONLINE) && !seesText("You invited a contact") && !exists(Ui.Info.Alias) + } + } + + private companion object { + const val INVITED = "invited to connect" + const val ACCEPTED = "accepted invitation" + const val CONTACT_ONLINE = "You will be connected when your contact's device is online, please wait or check later!" + const val GROUP_HOST_ONLINE = "You will be connected to group when the group host's device is online, please wait or check later!" + const val OWN_PROFILE = "Your profile alice will be shared." + const val SCAN_TO_CONNECT = "To connect, your contact can scan QR code or use the link in the app." + val SHARE = described("Share 1-time link") + val DELETE = described("Delete") + val PLAN_RESULT = Regex("invitation link: ok to connect|error: connection authorization failed.*") + val AUTH_FAILED = Regex("error: connection authorization failed - .*") + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/PreparedChatTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/PreparedChatTest.kt new file mode 100644 index 0000000000..73dfbd8f4f --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/PreparedChatTest.kt @@ -0,0 +1,149 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.becomeBot +import chat.simplex.app.ui.harness.createAddress +import chat.simplex.app.ui.harness.createOneTimeLink +import chat.simplex.app.ui.harness.openNewChatFromSearch +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class PreparedChatTest : UiTest() { + @Test fun preparedContactInvitationConnectButton() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = bob.createOneTimeLink() + bob.eventually("and ask them to connect: /c ") + alice.chatList.row("Private notes") + check(chatModel.chats.value.none { it.chatInfo.chatViewName == "bob" }) { "a chat named bob exists before the link is opened" } + val chat = openNewChatFromSearch(link, "bob") + poll(what = "the banner `Tap Connect to chat` and a `Connect` button with no message field") { + seesText("Tap Connect to chat") && exists(Ui.Chat.Connect) && "Connect" in textsOf(Ui.Chat.Connect) && !exists(Ui.Chat.Message) + } + backToChatList() + poll(what = "`Open to connect` on bob's row") { "Open to connect" in alice.chatList.rowTexts("bob") } + alice.chatList.open("bob") + click(Ui.Chat.Connect) + assertEquals("alice: contact is connected", bob.nextLine(CONNECT_TIMEOUT_MS)) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the message field in place of `Connect` with bob ready") { + exists(Ui.Chat.Message) && !exists(Ui.Chat.Connect) && !seesText("Tap Connect to chat") && + (alice.chatList.chat("bob").chatInfo as? ChatInfo.Direct)?.contact?.sndReady == true + } + chat.send("hi") + bob.expectsMessage("alice> hi", CONNECT_TIMEOUT_MS) + backToChatList() + poll(what = "`Open to connect` gone from bob's row") { "Open to connect" !in alice.chatList.rowTexts("bob") } + } + + @Test fun preparedContactAddressSendRequestWithMessage() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = bob.createAddress(autoAccept = false) + alice.chatList.row("Private notes") + val chat = openNewChatFromSearch(address, "bob") + poll(what = "the banner `Tap Connect to send request`, a message field and `Connect`") { + seesText("Tap Connect to send request") && exists(Ui.Chat.Message) && exists(Ui.Chat.Connect) && "Connect" in textsOf(Ui.Chat.Connect) + } + click(Ui.Chat.Connect) + poll(what = "`Send contact request?` with `Send request without message` and `Add message`") { + seesText("Send contact request?") && exists(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && + "Send request without message" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && "Add message" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the alert to close with `Connect` still shown") { !seesText("Send contact request?") && exists(Ui.Chat.Connect) } + typeInto(Ui.Chat.Message, "hello") + poll(what = "`Connect` hidden and the send button enabled once there is text") { !exists(Ui.Chat.Connect) && isEnabled(Ui.Chat.Send) } + pressEnter(Ui.Chat.Message) + hideKeyboard() + poll(what = "`Send contact request?` with `Send request` and `Cancel`") { + seesText("Send contact request?") && exists(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && + "Send request" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) && "Cancel" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.eventuallyAll( + listOf("alice wants to connect to you!", "to accept: /ac alice", "to reject: /rc alice (the sender will NOT be notified)"), + messages = listOf("alice> hello"), timeoutMs = CONNECT_TIMEOUT_MS + ) + poll(what = "the message field cleared and `contact should accept…` shown") { + editableText(Ui.Chat.Message).isNullOrEmpty() && seesText("contact should accept…") + } + chat.itemId("hello") + bob sends "/ac alice" + bob.nextLine().let { check(it.startsWith("alice: accepting contact request")) { "bob's next line after /ac is `$it`, not the acceptance" } } + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`contact should accept…` gone with bob ready") { + !seesText("contact should accept…") && (alice.chatList.chat("bob").chatInfo as? ChatInfo.Direct)?.contact?.sndReady == true + } + chat.send("hi") + bob.expectsMessage("alice> hi", CONNECT_TIMEOUT_MS) + } + + @Test fun preparedBotConnect() = uiTest { + val alice = app("alice"); val bob = cli("bob") + bob.becomeBot() + val address = bob.createAddress() + alice.chatList.row("Private notes") + openNewChatFromSearch(address, "bob") + poll(what = "the banner `Tap Connect to use bot` and `Connect` with no message field") { + seesText("Tap Connect to use bot") && exists(Ui.Chat.Connect) && "Connect" in textsOf(Ui.Chat.Connect) && + !exists(Ui.Chat.Message) && !seesText("Tap Connect to send request") + } + backToChatList() + poll(what = "`Open to use bot` on bob's row") { alice.chatList.rowTexts("bob").let { "Open to use bot" in it && "Open to connect" !in it } } + val chat = alice.chatList.open("bob") + click(Ui.Chat.Connect) + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the message field and the `Bot` banner after connecting") { + exists(Ui.Chat.Message) && !exists(Ui.Chat.Connect) && seesText("Bot") && !seesText("Tap Connect to use bot") + } + chat.send("hi") + bob.expectsMessage("alice> hi", CONNECT_TIMEOUT_MS) + } + + @Test fun preparedContactIncognitoBeforeConnect() = uiTest { + app("alice"); val bob = cli("bob") + val link = bob.createOneTimeLink() + bob.eventually("and ask them to connect: /c ") + val aliceId = chatModel.currentUser.value!!.userId + openNewChatFromSearch(link, "bob") + poll(what = "`Your profile` with alice selected and no `Incognito` option") { + seesText("Your profile") && exists(Ui.Chat.PickerUser(aliceId)) && !exists(Ui.Chat.PickerIncognito) + } + click(Ui.Chat.PickerUser(aliceId)) + poll(what = "the profile list with `Incognito`") { exists(Ui.Chat.PickerIncognito) } + click(Ui.Chat.PickerIncognito) + poll(what = "the list collapsed to `Incognito`") { exists(Ui.Chat.PickerIncognito) && !exists(Ui.Chat.PickerUser(aliceId)) } + click(Ui.Chat.Connect) + val shared = bob.eventuallyMatching(Regex("(\\S+): contact is connected"), CONNECT_TIMEOUT_MS).groupValues[1] + assertNotEquals("alice", shared) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the profile picker gone and the message field shown") { + !seesText("Your profile") && !exists(Ui.Chat.PickerIncognito) && exists(Ui.Chat.Message) + } + } + + @Test fun preparedContactChangeProfileBeforeConnect() = uiTest { + val alice = app("alice"); val bob = cli("bob") + alice.userPicker().profiles().apply { add("alice2"); close() } + alice.userPicker().switchTo("alice") + val link = bob.createOneTimeLink() + bob.eventually("and ask them to connect: /c ") + val aliceId = chatModel.users.first { it.user.displayName == "alice" }.user.userId + val alice2Id = chatModel.users.first { it.user.displayName == "alice2" }.user.userId + openNewChatFromSearch(link, "bob") + val bobChatId = chatModel.chatId.value + poll(what = "the picker with alice selected") { exists(Ui.Chat.PickerUser(aliceId)) && !exists(Ui.Chat.PickerUser(alice2Id)) } + click(Ui.Chat.PickerUser(aliceId)) + poll(what = "alice2 in the profile list") { exists(Ui.Chat.PickerUser(alice2Id)) } + click(Ui.Chat.PickerUser(alice2Id)) + poll(what = "alice2 active with bob's prepared chat still open and selected in the picker") { + chatModel.currentUser.value?.userId == alice2Id && chatModel.chatId.value == bobChatId && + exists(Ui.Chat.PickerUser(alice2Id)) && !exists(Ui.Chat.PickerUser(aliceId)) + } + click(Ui.Chat.Connect) + assertEquals("alice2: contact is connected", bob.nextLine(CONNECT_TIMEOUT_MS)) + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ProfileEditTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ProfileEditTest.kt new file mode 100644 index 0000000000..3ef91e3677 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ProfileEditTest.kt @@ -0,0 +1,136 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.labelled +import chat.simplex.app.ui.harness.leaveProfileEditorExpectingSaveAlert +import chat.simplex.app.ui.harness.profileEditorSave +import chat.simplex.app.ui.harness.setFullNameViaApi +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ProfileEditTest : UiTest() { + @Test fun editProfileBioAndFullName() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.profile() + poll(what = "the profile editor with `Bio:` and without `Full name:`") { + exists(Ui.Profile.Bio) && exists(Ui.Profile.Save) && seesText("Bio:") && !exists(Ui.Profile.FullName) && !seesText("Full name:") + } + check(!isEnabled(Ui.Profile.Save)) { "`Save and notify contacts` is enabled before any change" } + typeInto(Ui.Profile.Bio, "loves cats") + profileEditorSave() + bob.eventually("contact alice updated bio: loves cats", CONNECT_TIMEOUT_MS) + poll(what = "the profile editor to close after saving") { !exists(Ui.Profile.Save) } + + setFullNameViaApi("Alice Smith") + bob.eventually("contact alice updated full name: Alice Smith", CONNECT_TIMEOUT_MS) + alice.profile() + poll(what = "`Full name:` with `Alice Smith` and the saved bio") { + seesText("Full name:") && editableText(Ui.Profile.FullName) == "Alice Smith" && editableText(Ui.Profile.Bio) == "loves cats" + } + replaceText(Ui.Profile.FullName, "Alice Jones") + profileEditorSave() + bob.eventually("contact alice updated full name: Alice Jones", CONNECT_TIMEOUT_MS) + poll(what = "the profile editor to close and the full name saved") { + !exists(Ui.Profile.Save) && chatModel.currentUser.value?.profile?.fullName == "Alice Jones" + } + } + + @Test fun editProfileUnsavedChangesAlertExitAndSave() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.profile() + replaceText(Ui.Profile.Name, "carol") + leaveProfileEditorExpectingSaveAlert() + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the profile editor closed with alice unchanged") { !exists(Ui.Profile.Name) && chatModel.currentUser.value?.displayName == "alice" } + + alice.profile() + poll(what = "the profile editor showing `alice` again") { editableText(Ui.Profile.Name) == "alice" } + replaceText(Ui.Profile.Name, "alice2") + leaveProfileEditorExpectingSaveAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.eventually("contact alice changed to alice2", CONNECT_TIMEOUT_MS) + poll(what = "the profile editor closed with alice2 as the current user") { !exists(Ui.Profile.Name) && chatModel.currentUser.value?.displayName == "alice2" } + } + + @Test fun editProfileInvalidNameDisablesSave() = uiTest { + val alice = app("alice") + alice.userPicker().profiles().apply { add("work"); close() } + alice.userPicker().switchTo("alice") + alice.profile() + poll(what = "the profile editor with `alice`") { editableText(Ui.Profile.Name) == "alice" && exists(Ui.Profile.Save) } + check(!isEnabled(Ui.Profile.Save) && !exists(NAME_INVALID) && !exists(BIO_INVALID)) { "Save is enabled, or an error icon is shown, before any change" } + + replaceText(Ui.Profile.Name, "alice2") + poll(what = "Save enabled for a new valid name") { isEnabled(Ui.Profile.Save) } + replaceText(Ui.Profile.Name, "alice") + poll(what = "Save disabled again with the name changed back") { !isEnabled(Ui.Profile.Save) } + replaceText(Ui.Profile.Name, " ") + poll(what = "Save disabled for a blank name") { editableText(Ui.Profile.Name) == " " && !isEnabled(Ui.Profile.Save) } + replaceText(Ui.Profile.Name, "?alice2") + poll(what = "the invalid-name icon and Save disabled for `?alice2`") { exists(NAME_INVALID) && !isEnabled(Ui.Profile.Save) } + click(NAME_INVALID) + poll(what = "the `Invalid name!` alert offering `alice2`") { seesText("Invalid name!") && seesText("Correct name to alice2?") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the name corrected to `alice2` with Save enabled") { + editableText(Ui.Profile.Name) == "alice2" && !exists(NAME_INVALID) && !seesText("Invalid name!") && isEnabled(Ui.Profile.Save) + } + + typeInto(Ui.Profile.Bio, "x".repeat(MAX_BIO + 1)) + poll(what = "the bio error icon and Save disabled for a ${MAX_BIO + 1}-character bio") { exists(BIO_INVALID) && !isEnabled(Ui.Profile.Save) } + click(BIO_INVALID) + poll(what = "the `Bio too large` alert") { seesText("Bio too large") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert closed") { !seesText("Bio too large") } + replaceText(Ui.Profile.Bio, "x".repeat(MAX_BIO)) + poll(what = "no bio error icon and Save enabled for a $MAX_BIO-character bio") { !exists(BIO_INVALID) && isEnabled(Ui.Profile.Save) } + + replaceText(Ui.Profile.Name, "work") + poll(what = "Save enabled for the other profile's name") { !exists(NAME_INVALID) && isEnabled(Ui.Profile.Save) } + profileEditorSave() + poll(what = "the `Duplicate display name!` alert") { + seesText("Duplicate display name!") && seesText("You already have a chat profile with the same display name. Please choose another name.") + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the editor kept open with alice unchanged") { + !seesText("Duplicate display name!") && exists(Ui.Profile.Save) && + chatModel.currentUser.value?.let { it.displayName == "alice" && it.shortDescr == null } == true + } + } + + @Test fun editProfileDescription() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.profile() + poll(what = "`Add description` in the profile editor") { labelled(Ui.Profile.Description, "Add description") && !exists(Ui.Profile.DescriptionText) } + click(Ui.Profile.Description) + poll(what = "the description editor in place of the profile fields") { exists(Ui.Profile.DescriptionText) && !exists(Ui.Profile.Save) } + typeInto(Ui.Profile.DescriptionText, DESCRIPTION) + hideKeyboard() + goBack() + poll(what = "the profile fields back with `Edit description`") { + labelled(Ui.Profile.Description, "Edit description") && exists(Ui.Profile.Save) && !exists(Ui.Profile.DescriptionText) + } + profileEditorSave() + bob.eventually("contact alice updated description: $DESCRIPTION", CONNECT_TIMEOUT_MS) + poll(what = "the profile editor closed with the description saved") { + !exists(Ui.Profile.Save) && chatModel.currentUser.value?.profileDescription == DESCRIPTION + } + alice.profile() + poll(what = "`Edit description` after reopening") { labelled(Ui.Profile.Description, "Edit description") } + click(Ui.Profile.Description) + poll(what = "the saved description in the editor") { editableText(Ui.Profile.DescriptionText) == DESCRIPTION } + } + + private companion object { + val NAME_INVALID = Ui.Profile.Name.part("invalid") + val BIO_INVALID = Ui.Profile.Bio.part("invalid") + const val MAX_BIO = 160 + const val DESCRIPTION = "I like long walks" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ServersSummaryTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ServersSummaryTest.kt new file mode 100644 index 0000000000..ece070896c --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ServersSummaryTest.kt @@ -0,0 +1,94 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.isCount +import chat.simplex.app.ui.harness.localSmpServer +import chat.simplex.app.ui.harness.openServersSummary +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.chatlist.PresentedUserCategory +import org.junit.Test + +class ServersSummaryTest : UiTest() { + @Test fun serversSummaryShowsLocalSmpConnected() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hi" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `hi` on his row") { "hi" in alice.chatList.rowTexts("bob") } + val host = localSmpServer().hostnames.first() + check(!seesText("Servers info")) { "`Servers info` is shown before the indicator is clicked" } + val summary = openServersSummary() + poll(what = "the local SMP server under `Connected servers` with sent and received messages and an active connection") { + seesText("Connected servers") && exists(Ui.ServersSummary.SmpServer(host)) && + summary.value("Messages sent").isCount() && summary.value("Messages received").isCount() && summary.value("Active connections").isCount() + } + click(Ui.ServersSummary.SmpServer(host)) + poll(what = "`SMP server` with the local server's address, statistics and active connection") { + seesText("SMP server") && seesText("Server address") && !seesText("Connected servers") && seesText(host, substring = true) && + summary.value("Messages received").isCount() && summary.value("Active connections").isCount() + } + } + + @Test fun serversSummaryResetStatsAndReconnectAll() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hi" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `hi` on his row") { "hi" in alice.chatList.rowTexts("bob") } + val summary = openServersSummary() + poll(what = "messages sent and received counted") { summary.value("Messages sent").isCount() && summary.value("Messages received").isCount() } + click(Ui.ServersSummary.ResetStats) + poll(what = "the `Reset all statistics?` alert with `Reset`") { + seesText("Reset all statistics?") && seesText("Servers statistics will be reset - this cannot be undone!") && + "Reset" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "`-` for messages sent and received after the reset") { summary.value("Messages sent") == "-" && summary.value("Messages received") == "-" } + summary.details() + poll(what = "no subscriptions counted since the reset") { summary.subscribed() == "-" } + summary.backToSummary() + click(Ui.ServersSummary.ReconnectAll) + poll(what = "the `Reconnect servers?` alert") { + seesText("Reconnect servers?") && seesText("Reconnect all connected servers to force message delivery. It uses additional traffic.") && + exists(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the `Reconnect servers?` alert to close") { !seesText("Reconnect servers?") } + bob sends "@alice after reconnect" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's message counted as received after the reconnect") { summary.value("Messages received").isCount() } + check(!seesText("Error reconnecting servers")) { "reconnecting all servers showed an error" } + summary.details() + poll(what = "the resubscription after the reconnect counted under `Subscribed`") { summary.subscribed().isCount() } + } + + @Test fun serversSummaryAllVsCurrentProfile() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hi" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's `hi` on his row") { "hi" in alice.chatList.rowTexts("bob") } + val host = localSmpServer().hostnames.first() + openServersSummary() + poll(what = "the local SMP server listed for the only profile, without `Showing info for`") { + exists(Ui.ServersSummary.SmpServer(host)) && !exists(Ui.ServersSummary.UserCategory) && !seesText(SHOWING_INFO_FOR) + } + goBack() + poll(what = "`Servers info` to close") { !seesText("Servers info") } + alice.userPicker().profiles().apply { add("work"); close() } + val summary = openServersSummary() + poll(what = "`Showing info for` `All profiles` listing the local server and alice's sent messages") { + seesText(SHOWING_INFO_FOR) && "All profiles" in textsOf(Ui.ServersSummary.UserCategory) && + exists(Ui.ServersSummary.SmpServer(host)) && seesText("Connected servers") && summary.value("Messages sent").isCount() + } + click(Ui.ServersSummary.UserCategory) + click(Ui.ServersSummary.UserCategory.option(PresentedUserCategory.CURRENT_USER)) + poll(what = "`Current profile` for the connection-less work profile: no servers and `-` for messages sent") { + "Current profile" in textsOf(Ui.ServersSummary.UserCategory) && !exists(Ui.ServersSummary.SmpServer(host)) && + !seesText("Connected servers") && summary.value("Messages sent") == "-" + } + } + + private companion object { + const val SHOWING_INFO_FOR = "Showing info for" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/UserPickerTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/UserPickerTest.kt index 4e951dfb5b..1cf4da6faf 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/UserPickerTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/UserPickerTest.kt @@ -1,11 +1,22 @@ package chat.simplex.app.ui.main +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.closeTopScreen +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.labelled +import chat.simplex.app.ui.harness.pickerRowInert +import chat.simplex.app.ui.harness.pickerRowOpens +import chat.simplex.app.ui.harness.unreadBadge +import chat.simplex.app.ui.harness.userIdOf import chat.simplex.common.model.GroupProfile +import chat.simplex.common.platform.appPlatform import chat.simplex.common.platform.chatController import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.Ui +import chat.simplex.common.views.database.stopChatAsync import kotlinx.coroutines.runBlocking +import org.junit.Ignore import org.junit.Test class UserPickerTest : UiTest() { @@ -21,4 +32,81 @@ class UserPickerTest : UiTest() { alice.userPicker().switchTo("alice") alice.chatList.row("team") } + + @Test fun userPickerUnreadBadgePerUser() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.userPicker().profiles().apply { add("work"); close() } + connect(bob) + val aliceId = userIdOf("alice"); val workId = userIdOf("work") + alice.userPicker().switchTo("alice") + alice.userPicker() + poll(what = "work's avatar in the user picker without a badge") { exists(Ui.UserPicker.User(workId)) && unreadBadge(Ui.UserPicker.User(workId)) == null } + bob sends "@work one" + bob sends "@work two" + bob sends "@alice three" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "badge 2 on work's avatar") { unreadBadge(Ui.UserPicker.User(workId)) == "2" } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's message unread in alice's chat") { alice.chatList.chat("bob").chatStats.unreadCount == 1 } + check(unreadBadge(Ui.UserPicker.User(aliceId)) == null) { "the active user alice has an unread badge in the user picker" } + escape() + poll(what = "the user picker to close") { !exists(Ui.UserPicker.Sheet) } + alice.userPicker().switchTo("work") + alice.userPicker() + poll(what = "badge 1 on alice's avatar and none on the active work's") { + unreadBadge(Ui.UserPicker.User(aliceId)) == "1" && exists(Ui.UserPicker.User(workId)) && unreadBadge(Ui.UserPicker.User(workId)) == null + } + click(Ui.UserPicker.Profiles) + poll(what = "badge 1 on alice's row in the profiles list and none on work's") { + unreadBadge(Ui.Profiles.User(aliceId)) == "1" && exists(Ui.Profiles.User(workId)) && unreadBadge(Ui.Profiles.User(workId)) == null + } + } + + @Test fun userPickerOpensAndShowsCurrentUser() = uiTest { + val alice = app("alice") + val aliceId = userIdOf("alice") + alice.userPicker() + poll(what = "the user picker with alice and the rows `Create SimpleX address`, `Chat preferences`, `Your chat profiles`, `$DEVICES` and `Settings`") { + labelled(Ui.UserPicker.User(aliceId), "alice") && labelled(Ui.UserPicker.Address, "Create SimpleX address") && + labelled(Ui.UserPicker.ChatPreferences, "Chat preferences") && labelled(Ui.UserPicker.Profiles, "Your chat profiles") && + labelled(devicesRow, DEVICES) && labelled(Ui.UserPicker.Settings, "Settings") + } + click(Ui.UserPicker.User(aliceId)) + poll(what = "`Your current profile` with alice, and the user picker closed") { + seesText("Your current profile") && editableText(Ui.Profile.Name) == "alice" && !exists(Ui.UserPicker.Sheet) + } + closeTopScreen("the profile editor") { !exists(Ui.Profile.Name) } + + pickerRowOpens(alice, Ui.UserPicker.ChatPreferences, "`Your preferences`") { seesText("Your preferences") } + closeTopScreen("`Your preferences`") { !seesText("Your preferences") } + + pickerRowOpens(alice, Ui.UserPicker.Settings, "`Your settings`") { seesText("Your settings") && exists(Ui.Settings.Appearance) } + closeTopScreen("`Your settings`") { !exists(Ui.Settings.Appearance) } + + if (appPlatform.isDesktop) { + pickerRowOpens(alice, devicesRow, "`Link a mobile` with `This device name`") { seesText("This device name") } + } + } + + @Ignore("plan §15.4 item 55: disabled user picker rows are merged into the picker's clickable container, so the harness cannot find them") + @Test fun userPickerDisabledWhenChatStopped() = uiTest { + val alice = app("alice") + val aliceId = userIdOf("alice") + pickerRowOpens(alice, Ui.UserPicker.ChatPreferences, "`Your preferences` while chat runs") { seesText("Your preferences") } + closeTopScreen("`Your preferences`") { !seesText("Your preferences") } + + runBlocking { stopChatAsync(chatModel) } + poll(what = "chat stopped") { chatModel.chatRunning.value == false && exists(Ui.ChatList.Stopped) } + pickerRowInert(alice, Ui.UserPicker.User(aliceId), "`Your current profile`") { exists(Ui.Profile.Name) } + pickerRowInert(alice, Ui.UserPicker.Address, "the address screen") { exists(Ui.Address.Create) || exists(Ui.Address.Share) } + pickerRowInert(alice, Ui.UserPicker.ChatPreferences, "`Your preferences`") { seesText("Your preferences") } + pickerRowInert(alice, Ui.UserPicker.Profiles, "`Your chat profiles`") { exists(Ui.Profiles.Add) } + pickerRowInert(alice, devicesRow, "`$DEVICES`") { seesText("This device name") || seesText("Connect to desktop") } + pickerRowOpens(alice, Ui.UserPicker.Settings, "`Your settings` with chat stopped") { seesText("Your settings") && exists(Ui.Settings.Appearance) } + } + + private companion object { + val DEVICES: String get() = if (appPlatform.isDesktop) "Link a mobile" else "Use from desktop" + } + + private val devicesRow get() = if (appPlatform.isAndroid) Ui.UserPicker.UseFromDesktop else Ui.UserPicker.LinkMobile } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/AppearanceTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/AppearanceTest.kt index 114ceefc3f..32eac85221 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/AppearanceTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/AppearanceTest.kt @@ -1,11 +1,41 @@ package chat.simplex.app.ui.settings +import androidx.compose.ui.graphics.Color +import chat.simplex.app.ui.harness.StoredThemes import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.closeAppearance +import chat.simplex.app.ui.harness.currentWallpaperPreset +import chat.simplex.app.ui.harness.customizeTheme +import chat.simplex.app.ui.harness.jpegFile +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.yaml import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.getWallpaperFilePath +import chat.simplex.common.platform.readThemeOverrides +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.ui.theme.BlackColorPalette import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.ui.theme.DefaultTheme +import chat.simplex.common.ui.theme.ThemeColor +import chat.simplex.common.ui.theme.ThemeManager +import chat.simplex.common.ui.theme.ThemeOverrides +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.PresetWallpaper +import chat.simplex.common.views.helpers.WallpaperType +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test +import java.io.File + +private const val CURRENT_THEME = "CurrentTheme" +private const val SYSTEM_DARK_THEME = "SystemDarkTheme" +private const val ONE_HAND_UI = "OneHandUI" +private const val RED_HEX = "#ffff0000" +private val FLOWERS = PresetWallpaper.FLOWERS.filename +private val SCHOOL = PresetWallpaper.SCHOOL.filename class AppearanceTest : UiTest() { @Test fun appearance_settingsPersistAcrossRestart() = uiTest { @@ -28,4 +58,164 @@ class AppearanceTest : UiTest() { assertEquals(1.2f, after.fontScale, 0.001f) if (appPlatform.isDesktop) assertEquals(1.5f, after.zoom, 0.001f) } + + @Test fun appearance_colorMode_systemLightDark() = uiTest { + val appearance = app("alice").appearance() + check(CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME && storedSetting(CURRENT_THEME) != "LIGHT" && storedSetting(SYSTEM_DARK_THEME).let { it != "BLACK" && it != "SIMPLEX" }) { + "the color mode before the change: ${CurrentColors.value.name}, stored ${storedSetting(CURRENT_THEME)} / ${storedSetting(SYSTEM_DARK_THEME)}" + } + appearance.colorMode("LIGHT") + poll(what = "the light theme applied and stored") { + CurrentColors.value.base == DefaultTheme.LIGHT && storedSetting(CURRENT_THEME) == "LIGHT" && appearance.colorModeText() == "Light" + } + appearance.darkModeColors(DefaultTheme.BLACK.themeName) + poll(what = "Black stored as the dark mode colors") { storedSetting(SYSTEM_DARK_THEME) == "BLACK" } + check(CurrentColors.value.base == DefaultTheme.LIGHT && storedSetting(CURRENT_THEME) == "LIGHT") { + "choosing the dark mode colors in light mode changed the theme: ${CurrentColors.value.name}, stored ${storedSetting(CURRENT_THEME)}" + } + appearance.colorMode("DARK") + poll(what = "dark mode applied with the Black colors") { + CurrentColors.value.base == DefaultTheme.BLACK && CurrentColors.value.colors.background == BlackColorPalette.background && + storedSetting(CURRENT_THEME) == "BLACK" && appearance.colorModeText() == "Dark" && appearance.darkModeColorsText() == "Black" + } + appearance.darkModeColors(DefaultTheme.SIMPLEX.themeName) + poll(what = "the SimpleX colors applied at once in dark mode") { + CurrentColors.value.base == DefaultTheme.SIMPLEX && storedSetting(CURRENT_THEME) == "SIMPLEX" && storedSetting(SYSTEM_DARK_THEME) == "SIMPLEX" + } + appearance.colorMode("null") + poll(what = "the System color mode applied and stored") { + CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME && storedSetting(CURRENT_THEME) == DefaultTheme.SYSTEM_THEME_NAME && appearance.colorModeText() == "System" + } + } + + @Test fun appearance_wallpaperPreset_select() = uiTest { + val appearance = app("alice").appearance() + appearance.colorMode("LIGHT") + poll(what = "the light theme with the default wallpaper") { CurrentColors.value.base == DefaultTheme.LIGHT && currentWallpaperPreset() == SCHOOL } + check(StoredThemes.active(DefaultTheme.LIGHT)?.wallpaper?.preset != FLOWERS) { "the flowers wallpaper is stored before it was chosen" } + click(Ui.Appearance.Wallpaper(FLOWERS)) + poll(what = "the flowers wallpaper applied and stored for the light theme") { + currentWallpaperPreset() == FLOWERS && StoredThemes.active(DefaultTheme.LIGHT)?.wallpaper?.preset == FLOWERS + } + appearance.colorMode("DARK") + poll(what = "the dark theme with its default wallpaper") { CurrentColors.value.base == DefaultTheme.DARK && currentWallpaperPreset() == SCHOOL } + check(readThemeOverrides().none { it.base == DefaultTheme.DARK && it.wallpaper?.preset == FLOWERS }) { "the light theme's wallpaper was stored for the dark theme" } + appearance.colorMode("LIGHT") + poll(what = "the flowers wallpaper in the light theme again") { CurrentColors.value.base == DefaultTheme.LIGHT && currentWallpaperPreset() == FLOWERS } + restart() + poll(what = "the flowers wallpaper in the light theme after a restart") { CurrentColors.value.base == DefaultTheme.LIGHT && currentWallpaperPreset() == FLOWERS } + } + + @Test fun appearance_themeDestination_perUser() = uiTest { + val alice = app("alice") + val userId = chatModel.currentUser.value!!.userId + val dark = !CurrentColors.value.colors.isLight + val yamlBefore = StoredThemes.yamlBytes() + check(coreUiThemesPreset(userId, dark) == null) { "alice has a theme in the core before Apply to" } + alice.appearance() + click(Ui.Appearance.ApplyTo) + click(Ui.Appearance.ApplyTo.option(userId)) + poll(what = "Apply to showing alice") { textOf(Ui.Appearance.ApplyTo) == "alice" } + click(Ui.Appearance.Wallpaper(FLOWERS)) + poll(what = "the flowers wallpaper set as alice's theme and the save finished") { + chatModel.currentUser.value?.uiThemes?.preferredMode(dark)?.wallpaper?.preset == FLOWERS && UiIdle.inFlight().isEmpty() + } + assertEquals("alice's wallpaper in the core", FLOWERS, coreUiThemesPreset(userId, dark)) + check(StoredThemes.yamlBytes().contentEquals(yamlBefore) && readThemeOverrides().none { it.wallpaper?.preset == FLOWERS }) { + "the per-user wallpaper changed the app theme: ${readThemeOverrides()}" + } + } + + @Test fun appearance_reachableAppToolbars() = uiTest { + val alice = app("alice") + poll(what = "the new-chat button in the toolbar and no floating button") { exists(Ui.ChatList.NewChat) && !exists(Ui.ChatList.NewChatFab) } + check(storedSetting(ONE_HAND_UI) != "false") { "Reachable app toolbars is stored as off before the change" } + alice.appearance() + click(Ui.Appearance.OneHandUI) + poll(what = "Reachable app toolbars stored as off") { storedSetting(ONE_HAND_UI) == "false" } + closeAppearance() + poll(what = "the floating new-chat button instead of the toolbar button") { exists(Ui.ChatList.NewChatFab) && !exists(Ui.ChatList.NewChat) } + alice.appearance() + click(Ui.Appearance.OneHandUI) + poll(what = "Reachable app toolbars stored as on") { storedSetting(ONE_HAND_UI) == "true" } + closeAppearance() + poll(what = "the toolbar new-chat button again") { exists(Ui.ChatList.NewChat) && !exists(Ui.ChatList.NewChatFab) } + } + + @Test fun customizeTheme_editInterfaceColor() = uiTest { + val alice = app("alice") + enableDeveloperToolsViaApi() + val base = CurrentColors.value.base + val defaultAccent = CurrentColors.value.colors.primary + check(defaultAccent != Color.Red && StoredThemes.active(base)?.colors?.primary == null) { "the accent is red or stored before the change" } + customizeTheme(alice) + check(!exists(Ui.Appearance.ResetColors)) { "Reset colors is offered before any color was changed" } + click(Ui.Appearance.Accent) + replaceText(Ui.Appearance.ColorHex, "#ff0000") + poll(what = "the red accent applied and stored in themes.yaml") { CurrentColors.value.colors.primary == Color.Red && StoredThemes.active(base)?.colors?.primary == RED_HEX } + escape() + poll(what = "Customize theme with Reset colors") { exists(Ui.Appearance.ResetColors) && !exists(Ui.Appearance.ColorHex) } + click(Ui.Appearance.ResetColors) + poll(what = "the default accent applied, removed from themes.yaml, and Reset colors gone") { + CurrentColors.value.colors.primary == defaultAccent && StoredThemes.active(base)?.colors?.primary == null && !exists(Ui.Appearance.ResetColors) + } + } + + @Test fun customizeTheme_exportImportTheme() = uiTest { + val alice = app("alice") + val base = CurrentColors.value.base + setAccentViaApi(base, Color.Red) + customizeTheme(alice) + val exported = File(exchangeDir(), "exported.theme") + check(!exported.exists()) { "the export file exists before the export" } + saveFileAs(exported) { click(Ui.Appearance.ExportTheme) } + poll(what = "the exported theme file with the accent") { exported.exists() && "accent" in exported.readText() } + val text = exported.readText() + val theme = yaml.decodeFromString(ThemeOverrides.serializer(), text) + check("themeId" !in text && theme.base == base && theme.colors.primary == RED_HEX) { "exported theme:\n$text" } + click(Ui.Appearance.ResetColors) + poll(what = "the accent reset before the import") { CurrentColors.value.colors.primary != Color.Red && StoredThemes.active(base)?.colors?.primary == null } + openFile(exported) { click(Ui.Appearance.ImportTheme) } + poll(what = "the imported red accent applied and stored, with its base theme selected") { + CurrentColors.value.colors.primary == Color.Red && StoredThemes.active(base)?.colors?.primary == RED_HEX && storedSetting(CURRENT_THEME) == base.themeName + } + val yamlBefore = StoredThemes.yamlBytes() + val invalid = File(exchangeDir(), "invalid.theme").apply { writeText("colors: [accent") } + openFile(invalid) { click(Ui.Appearance.ImportTheme) } + poll(what = "the Import theme error alert") { seesText("Import theme error") && AlertManager.shared.hasAlertsShown() } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the Import theme error alert to close") { !AlertManager.shared.hasAlertsShown() } + check(StoredThemes.yamlBytes().contentEquals(yamlBefore) && CurrentColors.value.colors.primary == Color.Red) { "the invalid theme changed the stored themes" } + } + + @Test fun appearance_wallpaperImage_import() = uiTest { + val alice = app("alice") + alice.appearance() + val base = CurrentColors.value.base + val image = jpegFile("wallpaper.jpg") + waitFor(Ui.Appearance.ColorMode) + check(CurrentColors.value.wallpaper.type !is WallpaperType.Image && StoredThemes.active(base)?.wallpaper?.imageFile == null && !exists(Ui.Appearance.RemoveImage)) { + "an image wallpaper or `Remove image` before the import: ${CurrentColors.value.wallpaper.type}" + } + scrollTo(Ui.Appearance.WallpaperImage) + openFile(image) { click(Ui.Appearance.WallpaperImage) } + var name: String? = null + poll(what = "the image applied as the wallpaper, copied to the wallpapers folder, stored for the theme, with `Remove image`") { + name = (CurrentColors.value.wallpaper.type as? WallpaperType.Image)?.filename + name?.let { n -> File(getWallpaperFilePath(n)).let { it.exists() && it.readBytes().contentEquals(image.readBytes()) } && StoredThemes.active(base)?.wallpaper?.imageFile == n } == true && + exists(Ui.Appearance.RemoveImage) + } + val copy = File(getWallpaperFilePath(name!!)) + click(Ui.Appearance.RemoveImage) + poll(what = "the image wallpaper gone, the copied image deleted and the theme without it") { + CurrentColors.value.wallpaper.type !is WallpaperType.Image && !copy.exists() && StoredThemes.active(base)?.wallpaper?.imageFile == null && !exists(Ui.Appearance.RemoveImage) + } + } + + private fun enableDeveloperToolsViaApi() = appPrefs.developerTools.set(true) + + private fun setAccentViaApi(base: DefaultTheme, color: Color) = ThemeManager.saveAndApplyThemeColor(base, ThemeColor.PRIMARY, color) + + private fun coreUiThemesPreset(userId: Long, dark: Boolean): String? = + runBlocking { chatController.listUsers(null) }.first { it.user.userId == userId }.user.uiThemes?.preferredMode(dark)?.wallpaper?.preset } diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/ChatDataTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/ChatDataTest.kt new file mode 100644 index 0000000000..13e3ce2685 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/ChatDataTest.kt @@ -0,0 +1,315 @@ +package chat.simplex.app.ui.settings + +import chat.simplex.app.ui.harness.App +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.DB_WORK_TIMEOUT_MS +import chat.simplex.app.ui.harness.KnownAppIssue +import chat.simplex.app.ui.harness.PassphraseScreen +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.chatData +import chat.simplex.app.ui.harness.chatRunningInCore +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.onboarding +import chat.simplex.app.ui.harness.typeSecret +import chat.simplex.app.ui.harness.userIdOf +import chat.simplex.common.model.API +import chat.simplex.common.model.CC +import chat.simplex.common.model.CIFileStatus +import chat.simplex.common.model.CR +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatItemTTL +import chat.simplex.common.platform.appFilesDir +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.onboarding.OnboardingStage +import kotlinx.coroutines.runBlocking +import org.junit.Test +import java.io.File +import java.util.zip.ZipFile + +class ChatDataTest : UiTest() { + @Test fun chatData_ttlDecrease_alertConfirm() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice $KEPT" + poll(what = "bob's first message on the row") { KEPT in alice.chatList.rowTexts("bob") } + bob sends "@alice $EXPIRED" + poll(what = "bob's second message on the row") { EXPIRED in alice.chatList.rowTexts("bob") } + backdateItemViaSql(alice.chatList.chat("bob").chatItems.last { it.content.text == EXPIRED }.id) + val data = chatData(alice) + check(seesText("This setting applies to messages in your current chat profile alice.")) { "the Messages footer does not name alice" } + check(data.ttlText() == "never" && coreTtl() == ChatItemTTL.None) { "TTL before the change: picker ${data.ttlText()}, core ${coreTtl()}" } + data.selectTtl(ChatItemTTL.Day) + awaitDeletionAlert() + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the picker back to never after Cancel") { !AlertManager.shared.hasAlertsShown() && data.ttlText() == "never" } + check(coreTtl() == ChatItemTTL.None) { "Cancel applied the TTL: ${coreTtl()}" } + data.selectTtl(ChatItemTTL.Day) + awaitDeletionAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "1 day in the picker and in the core") { data.ttlText() == "1 day" && coreTtl() == ChatItemTTL.Day } + data.selectTtl(ChatItemTTL.Week) + poll(what = "1 week in the picker and in the core") { data.ttlText() == "1 week" && coreTtl() == ChatItemTTL.Week } + check(!AlertManager.shared.hasAlertsShown()) { "a longer TTL asked for confirmation" } + data.close() + poll(what = "bob's row previewing the kept message") { alice.chatList.rowTexts("bob").let { KEPT in it && EXPIRED !in it } } + val chat = alice.chatList.open("bob") + poll(what = "bob's chat with the kept message and without the expired one") { chat.itemTexts().let { KEPT in it && EXPIRED !in it } } + } + + @Test fun chatData_ttlPerProfile() = uiTest { + val alice = app("alice") + val aliceId = userIdOf("alice") + var data = chatData(alice) + check(footerNames("alice") && data.ttlText() == "never" && DELETE_ALL_FILES in textsOf(Ui.Database.DeleteFiles)) { + "Chat data with one profile: TTL ${data.ttlText()}, files item ${textsOf(Ui.Database.DeleteFiles)}" + } + data.selectTtl(ChatItemTTL.Week) + awaitDeletionAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "1 week for alice in the picker and in the core") { data.ttlText() == "1 week" && coreTtlOf(aliceId) == ChatItemTTL.Week } + data.close() + alice.userPicker().profiles().apply { add("work"); close() } + val workId = userIdOf("work") + data = chatData(alice) + poll(what = "Chat data for work with its own TTL and the files item for all profiles") { + footerNames("work") && data.ttlText() == "never" && DELETE_FILES_ALL_PROFILES in textsOf(Ui.Database.DeleteFiles) + } + check(coreTtlOf(workId) == ChatItemTTL.None) { "work's TTL in the core: ${coreTtlOf(workId)}" } + data.selectTtl(ChatItemTTL.Day) + awaitDeletionAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "1 day for work in the picker and in the core") { data.ttlText() == "1 day" && coreTtlOf(workId) == ChatItemTTL.Day } + data.close() + alice.userPicker().switchTo("alice") + data = chatData(alice) + poll(what = "Chat data for alice with her TTL, unchanged by work's") { + footerNames("alice") && data.ttlText() == "1 week" && DELETE_FILES_ALL_PROFILES in textsOf(Ui.Database.DeleteFiles) && coreTtlOf(aliceId) == ChatItemTTL.Week + } + } + + @Test fun dbMgmt_stopAndStartChat() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val db = chatData(alice).management() + check(seesText("Chat is running") && chatRunningInCore() && storedSetting("ChatStopped") != "true") { "the chat is not running before the stop" } + click(Ui.Database.RunChat) + awaitStopAlert() + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the chat still running after Cancel") { !AlertManager.shared.hasAlertsShown() && seesText("Chat is running") } + check(chatRunningInCore() && storedSetting("ChatStopped") != "true") { "Cancel stopped the chat" } + click(Ui.Database.RunChat) + awaitStopAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the chat stopped on screen, on disk and in the core") { + seesText("Chat is stopped") && seesText(MOST_RECENT_DB, substring = true) && storedSetting("ChatStopped") == "true" && !chatRunningInCore() + } + bob sends "@alice $WHILE_STOPPED" + click(Ui.Database.RunChat) + poll(what = "the chat running again on screen, on disk and in the core") { + seesText("Chat is running") && !seesText(MOST_RECENT_DB, substring = true) && storedSetting("ChatStopped") == "false" && chatRunningInCore() + } + db.close() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the message bob sent while the chat was stopped") { WHILE_STOPPED in alice.chatList.rowTexts("bob") } + alice.chatList.open("bob").send(BACK_ONLINE) + bob.expectsMessage("alice> $BACK_ONLINE", CONNECT_TIMEOUT_MS) + } + + @Test fun dbMgmt_importArchive_restoresData() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice $BEFORE_EXPORT" + poll(what = "bob's message before the export") { BEFORE_EXPORT in alice.chatList.rowTexts("bob") } + writeNote(alice, NOTE_BEFORE_EXPORT) + setLinkPreviewsPrefDirectly(false) + check(storedSetting("PrivacyLinkPreviews") == "false") { "link previews are not stored as off before the export" } + val zip = File(exchangeDir(), "export.zip") + val db = chatData(alice).management() + click(Ui.Database.Export) + poll(what = "the Set passphrase to export alert") { + seesText("Set passphrase to export") && seesText("Database is encrypted using a random passphrase. Please change it before exporting.") + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + waitFor(Ui.DbPassphrase.New) + val pass = PassphraseScreen(this) + pass.setFirst(PASSPHRASE) + pass.close() + check(!zip.exists() && seesText("Chat is running")) { "before the export: archive exists ${zip.exists()}, texts ${texts().take(30)}" } + saveFileAs(zip) { click(Ui.Database.Export) } + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the archive with both databases at the chosen path") { + zip.length() > 0 && ZipFile(zip).use { z -> z.entries().toList().map { it.name }.containsAll(listOf("simplex_v1_chat.db", "simplex_v1_agent.db")) } + } + poll(what = "the chat left stopped after the export") { seesText("Chat is stopped") } + setLinkPreviewsPrefDirectly(true) + check(storedSetting("PrivacyLinkPreviews") == "true") { "link previews are not stored as on after the export" } + click(Ui.Database.RunChat) + poll(what = "the chat running again") { seesText("Chat is running") && chatModel.chatRunning.value == true } + db.close() + writeNote(alice, NOTE_AFTER_EXPORT) + chatData(alice).management() + openFile(zip) { click(Ui.Database.Import) } + poll(what = "the Import chat database? alert") { seesText("Import chat database?") && "Import" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the Chat database imported alert") { seesText("Chat database imported") && seesText("Restart the app to use imported chat database.") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the passphrase prompt with Save passphrase and open chat") { + seesText("Encrypted database") && seesText("Database passphrase is required to open chat.") && "Save passphrase and open chat" in textsOf(Ui.DbError.Open) + } + check(storedSetting("EncryptedDBPassphrase") == null) { "the import left the old passphrase stored" } + typeSecret(Ui.DbError.Passphrase, PASSPHRASE) + click(Ui.DbError.Open) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the Start chat? alert") { seesText("Start chat?") && seesText(START_CHAT_TEXT) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the chat started in the core with bob from the archive listed") { chatRunningInCore() && alice.chatList.shown("bob") } + check(storedSetting("PrivacyLinkPreviews") == "false" && storedSetting("ShouldImportAppSettings") == "false" && storedSetting("EncryptedDBPassphrase") != null) { + "after the import: PrivacyLinkPreviews=${storedSetting("PrivacyLinkPreviews")}, ShouldImportAppSettings=${storedSetting("ShouldImportAppSettings")}, passphrase stored ${storedSetting("EncryptedDBPassphrase") != null}" + } + val notes = alice.chatList.open("Private notes") + poll(what = "the notes from the archive, without the note written after the export") { + notes.itemTexts().let { NOTE_BEFORE_EXPORT in it && NOTE_AFTER_EXPORT !in it } + } + backToChatList() + val chat = alice.chatList.open("bob") + chat.itemId(BEFORE_EXPORT) + chat.send(AFTER_IMPORT) + bob.expectsMessage("alice> $AFTER_IMPORT", CONNECT_TIMEOUT_MS) + } + + @KnownAppIssue("15.5 item 7", "waiting for the Error importing chat database alert") + @Test fun dbMgmt_importInvalidArchive() = uiTest { + val alice = app("alice") + writeNote(alice, NOTE_BEFORE_IMPORT) + val bad = File(exchangeDir(), "not-an-archive.zip").apply { writeText("not a zip archive") } + val db = chatData(alice).management() + openFile(bad) { click(Ui.Database.Import) } + poll(what = "the Import chat database? alert") { seesText("Import chat database?") && "Import" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the Error importing chat database alert") { seesText("Error importing chat database") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "alice still the user, without the welcome screen") { + !AlertManager.shared.hasAlertsShown() && chatModel.currentUser.value?.displayName == "alice" && !exists(Ui.Onboarding.GetStarted) + } + check(coreUserNames() == listOf("alice")) { "users after the failed import: ${coreUserNames()}" } + db.close() + poll(what = "alice's note still listed") { NOTE_BEFORE_IMPORT in alice.chatList.rowTexts("Private notes") } + } + + @Test fun dbMgmt_deleteDatabase() = uiTest { + val alice = app("alice") + chatData(alice).management() + click(Ui.Database.Delete) + awaitDeleteAlert() + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the database screen after Cancel") { !AlertManager.shared.hasAlertsShown() && exists(Ui.Database.Delete) } + check(coreUserNames() == listOf("alice")) { "users after Cancel: ${coreUserNames()}" } + click(Ui.Database.Delete) + awaitDeleteAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the Chat database deleted alert") { seesText("Chat database deleted") && seesText("Restart the app to create a new chat profile.") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the welcome screen without a restart") { + exists(Ui.Onboarding.GetStarted) && onboarding().stage == OnboardingStage.Step1_SimpleXInfo && chatModel.currentUser.value == null + } + check(coreUserNames().isEmpty()) { "users after the deletion: ${coreUserNames()}" } + } + + @Test fun chatData_deleteFilesAndMedia() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "/img @alice $FIXTURE_JPG" + bob expectsMessage "/f @alice $FIXTURE_JPG" + bob expects "use /fc 1 to cancel sending" + bob.eventually("completed uploading file 1 (test.jpg) for alice", CONNECT_TIMEOUT_MS) + bob sends "@alice $KEPT" + val chat = alice.chatList.open("bob") + chat.itemId(KEPT) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's image to be received") { imageStatus() is CIFileStatus.RcvComplete } + backToChatList() + val data = chatData(alice) + check(seesText("1 file(s) with total size of", substring = true) && !seesText("No received or sent files")) { "the files footer before the deletion: ${texts().take(30)}" } + check(!appFilesDir.listFiles().isNullOrEmpty()) { "no received file on disk before the deletion" } + click(Ui.Database.DeleteFiles) + poll(what = "the Delete files and media? alert") { + seesText("Delete files and media?") && seesText(DELETE_FILES_TEXT) && "Delete" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "no files on screen or on disk and the chat running again") { + seesText("No received or sent files") && appFilesDir.listFiles().isNullOrEmpty() && chatModel.chatRunning.value == true && chatRunningInCore() + } + data.close() + val after = alice.chatList.open("bob") + after.itemId(KEPT) + check(chatModel.chatsContext.chatItems.value.any { it.file?.fileName == "test.jpg" }) { "the image message was deleted with its file" } + after.send(FILES_GONE) + bob.expectsMessage("alice> $FILES_GONE", CONNECT_TIMEOUT_MS) + } + + private fun UiScope.writeNote(app: App, text: String) { + val notes = app.chatList.open("Private notes") + notes.send(text) + notes.itemId(text) + backToChatList() + } + + private fun UiScope.awaitDeletionAlert() = + poll(what = "the Enable automatic message deletion? alert with Delete messages") { + seesText("Enable automatic message deletion?") && "Delete messages" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + + private fun UiScope.awaitStopAlert() = + poll(what = "the Stop chat? alert with Stop") { + seesText("Stop chat?") && seesText(STOP_CHAT_TEXT) && "Stop" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + + private fun UiScope.awaitDeleteAlert() = + poll(what = "the Delete chat profile? alert with Delete") { + seesText("Delete chat profile?") && seesText(DELETE_PROFILE_TEXT) && "Delete" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + + private fun backdateItemViaSql(itemId: Long) { + val r = runBlocking { chatController.sendCmd(null, CC.Console("/sql chat UPDATE chat_items SET created_at = '2020-01-01 00:00:00' WHERE chat_item_id = $itemId")) } + check(r !is API.Error) { "backdating chat item $itemId failed: ${r.responseType} ${r.details}" } + } + + private fun setLinkPreviewsPrefDirectly(on: Boolean) = appPrefs.privacyLinkPreviews.set(on) + + private fun coreTtl(): ChatItemTTL = runBlocking { chatController.getChatItemTTL(null) } + + private fun coreTtlOf(userId: Long): ChatItemTTL { + val r = runBlocking { chatController.sendCmd(null, CC.APIGetChatItemTTL(userId)) } + val ttl = r.result as? CR.ChatItemTTL ?: error("/_ttl $userId failed: ${r.responseType} ${r.details}") + return ttl.chatItemTTL?.let { ChatItemTTL.fromSeconds(it) } ?: ChatItemTTL.None + } + + private fun UiScope.footerNames(name: String) = seesText("This setting applies to messages in your current chat profile $name.") + + private fun coreUserNames(): List = runBlocking { chatController.listUsers(null) }.map { it.user.displayName } + + private fun imageStatus(): CIFileStatus? = chatModel.chatsContext.chatItems.value.lastOrNull { it.file?.fileName == "test.jpg" }?.file?.fileStatus + + private companion object { + const val KEPT = "kept message" + const val DELETE_ALL_FILES = "Delete all files" + const val DELETE_FILES_ALL_PROFILES = "Delete files for all chat profiles" + const val EXPIRED = "expired message" + const val WHILE_STOPPED = "sent while stopped" + const val BACK_ONLINE = "back online" + const val BEFORE_EXPORT = "before export" + const val AFTER_IMPORT = "after import" + const val NOTE_BEFORE_EXPORT = "note written before the export" + const val NOTE_AFTER_EXPORT = "note written after the export" + const val NOTE_BEFORE_IMPORT = "note written before the import" + const val FILES_GONE = "files gone" + const val PASSPHRASE = "uitest-export-8Wd" + const val FIXTURE_JPG = "tests/fixtures/test.jpg" + const val MOST_RECENT_DB = "You must use the most recent version of your chat database on one device ONLY" + const val STOP_CHAT_TEXT = "Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." + const val START_CHAT_TEXT = "Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." + const val DELETE_PROFILE_TEXT = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." + const val DELETE_FILES_TEXT = "This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/DatabaseEncryptionTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/DatabaseEncryptionTest.kt new file mode 100644 index 0000000000..d5ee94e9c8 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/DatabaseEncryptionTest.kt @@ -0,0 +1,160 @@ +package chat.simplex.app.ui.settings + +import chat.simplex.app.ui.harness.App +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.DB_WORK_TIMEOUT_MS +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.chatData +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.typeSecret +import chat.simplex.common.model.API +import chat.simplex.common.model.CC +import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatDatabaseFileName +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.DBMigrationResult +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class DatabaseEncryptionTest : UiTest() { + @Test fun dbEncrypt_changePassphrase() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice $BEFORE_CHANGE" + poll(what = "bob's message on the row") { BEFORE_CHANGE in alice.chatList.rowTexts("bob") } + val pass = chatData(alice).management().passphrase() + check(!exists(Ui.DbPassphrase.Current) && storedSetting("InitialRandomDBPassphrase") == "true") { "a passphrase is set before the first one" } + pass.setFirst(FIRST) + val storedFirst = storedSetting("EncryptedDBPassphrase") + check(storedFirst != null && storedSetting("InitialRandomDBPassphrase") == "false") { "after the first passphrase: stored ${storedFirst != null}, random ${storedSetting("InitialRandomDBPassphrase")}" } + pass.change(current = "wrong-$FIRST", new = SECOND) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the Wrong passphrase! alert") { seesText("Wrong passphrase!") && seesText("Please enter correct current passphrase.") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + check(storedSetting("EncryptedDBPassphrase") == storedFirst) { "a wrong current passphrase changed the stored passphrase" } + pass.reopen() + pass.change(current = FIRST, new = SECOND) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the Database encrypted! alert") { seesText("Database encrypted!") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the chat running again with the new passphrase stored") { + chatModel.chatRunning.value == true && storedSetting("EncryptedDBPassphrase").let { it != null && it != storedFirst } + } + val after = restart() + poll(what = "the chat list without a passphrase prompt") { after.chatList.shown("bob") && !seesText("Encrypted database") && !seesText("Wrong database passphrase") } + val chat = after.chatList.open("bob") + chat.itemId(BEFORE_CHANGE) + chat.send(AFTER_CHANGE) + bob.expectsMessage("alice> $AFTER_CHANGE", CONNECT_TIMEOUT_MS) + } + + @Test fun dbEncrypt_removeFromSettings() = uiTest { + val alice = app("alice") + writeNote(alice) + chatData(alice).management().passphrase().setFirst(FIRST) + check(seesText(onPlatform(STORED_DESKTOP, STORED_ANDROID), substring = true) && seesText(NO_RECOVERY, substring = true)) { "the stored-passphrase footer: ${texts().take(30)}" } + val stored = storedSetting("EncryptedDBPassphrase") + check(stored != null && storedSetting("StoreDBPassphrase") == "true") { "no passphrase stored before Remove" } + click(Ui.DbPassphrase.Save) + awaitRemoveAlert() + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + poll(what = "the passphrase still stored after Cancel") { !AlertManager.shared.hasAlertsShown() && seesText(onPlatform(STORED_DESKTOP, STORED_ANDROID), substring = true) } + check(storedSetting("EncryptedDBPassphrase") == stored && storedSetting("StoreDBPassphrase") == "true") { "Cancel removed the stored passphrase" } + click(Ui.DbPassphrase.Save) + awaitRemoveAlert() + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the passphrase removed on screen and on disk") { + seesText(NOT_STORED) && seesText(NO_RECOVERY, substring = true) && storedSetting("EncryptedDBPassphrase") == null && storedSetting("StoreDBPassphrase") == "false" + } + var current = restart(ready = { passphrasePrompted() }, what = "the passphrase prompt") + poll(what = "the Encrypted database prompt with Open chat") { + seesText("Encrypted database") && seesText("Database passphrase is required to open chat.") && "Open chat" in textsOf(Ui.DbError.Open) + } + typeSecret(Ui.DbError.Passphrase, FIRST.dropLast(1)) + click(Ui.DbError.Open) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the Wrong passphrase! alert") { + seesText("Wrong passphrase!") && seesText("Enter correct passphrase.") && chatModel.chatDbStatus.value is DBMigrationResult.ErrorNotADatabase + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + typeSecret(Ui.DbError.Passphrase, FIRST.takeLast(1)) + click(Ui.DbError.Open) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the chat list after the right passphrase") { chatModel.chatDbStatus.value == DBMigrationResult.OK && current.chatList.shown(NOTES) } + check(storedSetting("EncryptedDBPassphrase") == null) { "Open chat stored the passphrase" } + val notes = current.chatList.open(NOTES) + notes.itemId(NOTE) + backToChatList() + chatData(current).management().passphrase() + check(seesText(NOT_STORED)) { "the passphrase screen after Open chat: ${texts().take(30)}" } + click(Ui.DbPassphrase.Save) + poll(what = "storing turned on but nothing stored yet") { + seesText(onPlatform(WILL_STORE_DESKTOP, WILL_STORE_ANDROID)) && storedSetting("StoreDBPassphrase") == "true" && storedSetting("EncryptedDBPassphrase") == null + } + check(!AlertManager.shared.hasAlertsShown()) { "turning storing on asked for confirmation" } + current = restart(ready = { passphrasePrompted() }, what = "the passphrase prompt") + poll(what = "the Encrypted database prompt with Save passphrase and open chat") { + seesText("Encrypted database") && "Save passphrase and open chat" in textsOf(Ui.DbError.Open) + } + typeSecret(Ui.DbError.Passphrase, FIRST) + click(Ui.DbError.Open) + poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the chat list with the passphrase stored") { + chatModel.chatDbStatus.value == DBMigrationResult.OK && current.chatList.shown(NOTES) && storedSetting("EncryptedDBPassphrase") != null + } + current = restart() + poll(what = "the chat list without a passphrase prompt") { current.chatList.shown(NOTES) && !seesText("Encrypted database") } + } + + @Test fun dbError_incompatibleDowngrade() = uiTest { + app("alice") + waitFor(Ui.ChatList.Search) + check(chatModel.chatDbStatus.value == DBMigrationResult.OK && !seesText(INCOMPATIBLE)) { "database status before the fixture: ${chatModel.chatDbStatus.value}" } + addFutureMigrationViaSql() + restart(ready = { chatModel.chatDbStatus.value is DBMigrationResult.ErrorMigration && !chatModel.ctrlInitInProgress.value }, what = "the database migration error") + poll(what = "Incompatible database version naming the file and the migration") { + seesText(INCOMPATIBLE) && seesText("File: $chatDatabaseFileName") && + seesText("Error: database version is newer than the app, but no down migration for: $FUTURE_MIGRATION") + } + check(!exists(Ui.DbError.Passphrase) && !exists(Ui.ChatList.Search)) { "the incompatible database screen offers a passphrase field or the chat list" } + } + + private fun UiScope.writeNote(app: App) { + val notes = app.chatList.open(NOTES) + notes.send(NOTE) + notes.itemId(NOTE) + backToChatList() + } + + private fun UiScope.awaitRemoveAlert() = + poll(what = "the Remove passphrase alert with Remove") { + seesText(onPlatform("Remove passphrase from settings?", "Remove passphrase from Keystore?")) && "Remove" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + + private fun passphrasePrompted(): Boolean = + chatModel.chatDbStatus.value is DBMigrationResult.ErrorNotADatabase && !chatModel.ctrlInitInProgress.value + + private fun onPlatform(desktop: String, android: String): String = if (appPlatform.isDesktop) desktop else android + + private fun addFutureMigrationViaSql() { + val r = runBlocking { chatController.sendCmd(null, CC.Console("/sql chat INSERT INTO migrations (name, ts, down) VALUES ('$FUTURE_MIGRATION', '2999-12-31 00:00:00', NULL)")) } + check(r !is API.Error) { "adding the future migration failed: ${r.responseType} ${r.details}" } + } + + private companion object { + const val FIRST = "uitest-first-7Kq" + const val SECOND = "uitest-second-3Zp" + const val BEFORE_CHANGE = "before passphrase change" + const val AFTER_CHANGE = "after passphrase change" + const val NOTES = "Private notes" + const val NOTE = "kept across restarts" + const val FUTURE_MIGRATION = "29991231_uitest_newer" + const val INCOMPATIBLE = "Incompatible database version" + const val STORED_DESKTOP = "The passphrase is stored in settings as plaintext." + const val STORED_ANDROID = "is used to securely store passphrase - it allows notification service to work." + const val NO_RECOVERY = "you will NOT be able to recover or change passphrase if you lose it." + const val NOT_STORED = "You have to enter passphrase every time the app starts - it is not stored on the device." + const val WILL_STORE_DESKTOP = "The passphrase will be stored in settings as plaintext after you change it or restart the app." + const val WILL_STORE_ANDROID = "Android Keystore will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving notifications." + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/HelpTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/HelpTest.kt new file mode 100644 index 0000000000..9ba6b29fe9 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/HelpTest.kt @@ -0,0 +1,38 @@ +package chat.simplex.app.ui.settings + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.helpAndSupport +import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.platform +import chat.simplex.common.ui.Ui +import org.junit.Test + +private val HELP_ROWS = listOf( + "How to use it", "What's new", "About SimpleX Chat", + "Follow SimpleX Network News", "Send questions and ideas", "Send us email", "Star on GitHub", +) +private const val WELCOME = "Welcome alice!" +private const val HELP_TEXT = "Thank you for installing SimpleX Chat!" +private const val ABOUT_TEXT = "Private and secure messaging." +private const val WHY_BUILT = "Why SimpleX is built." + +class HelpTest : UiTest() { + @Test fun help_layoutHowToUseAndAbout() = uiTest { + val alice = app("alice") + helpAndSupport(alice) + check(HELP_ROWS.all { seesText(it) }) { "Help & support rows missing: ${HELP_ROWS.filterNot { seesText(it) }}" } + check(seesText("Rate the app") == appPlatform.isAndroid) { "Rate the app shown: ${seesText("Rate the app")} on ${appPlatform.name}" } + check(seesText("Contribute") == !platform.androidIsPlayStoreBuild) { "Contribute shown: ${seesText("Contribute")}" } + check(!seesText(WELCOME) && !seesText(ABOUT_TEXT)) { "How to use it or About is shown before it was opened" } + click(Ui.Help.HowToUse) + poll(what = "How to use it with alice's welcome and the help text") { seesText(WELCOME) && seesText(HELP_TEXT) && !exists(Ui.Help.HowToUse) } + escape() + poll(what = "Help & support again") { exists(Ui.Help.About) && !seesText(WELCOME) } + click(Ui.Help.About) + poll(what = "About SimpleX Chat without the onboarding buttons") { + seesText(ABOUT_TEXT) && !exists(Ui.Help.About) && !exists(Ui.Onboarding.WhyBuilt) && !seesText(WHY_BUILT) + } + escape() + poll(what = "Help & support after About") { exists(Ui.Help.About) && !seesText(ABOUT_TEXT) } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/NetworkProxyTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/NetworkProxyTest.kt new file mode 100644 index 0000000000..4fdec25ad9 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/NetworkProxyTest.kt @@ -0,0 +1,390 @@ +package chat.simplex.app.ui.settings + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.SOCKS_HOST +import chat.simplex.app.ui.harness.SocksProxy +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList +import chat.simplex.app.ui.harness.closeDeveloperTools +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.coreNetworkConfig +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.localSmpServer +import chat.simplex.app.ui.harness.networkAndServers +import chat.simplex.app.ui.harness.saveNetworkConfigViaApi +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.HostMode +import chat.simplex.common.model.KeepAliveOpts +import chat.simplex.common.model.NetCfg +import chat.simplex.common.model.NetworkProxy +import chat.simplex.common.model.OnionHosts +import chat.simplex.common.model.SMPProxyFallback +import chat.simplex.common.model.SMPProxyMode +import chat.simplex.common.model.TransportSessionMode +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.helpers.AlertManager +import org.junit.Test + +private class NetSetting(val name: String, val pref: String, val new: Long, val oldText: String, val newText: String, val core: (NetCfg) -> Long) + +private val TIMEOUTS = listOf( + NetSetting("tcpConnectTimeout", "NetworkTCPConnectTimeoutInteractive", 20_000_000, "15 sec", "20 sec") { it.tcpConnectTimeout.interactiveTimeout }, + NetSetting("tcpConnectTimeoutBackground", "NetworkTCPConnectTimeoutBackground", 60_000_000, "45 sec", "60 sec") { it.tcpConnectTimeout.backgroundTimeout }, + NetSetting("tcpTimeout", "NetworkTCPTimeoutInteractive", 15_000_000, "10 sec", "15 sec") { it.tcpTimeout.interactiveTimeout }, + NetSetting("tcpTimeoutBackground", "NetworkTCPTimeoutBackground", 45_000_000, "30 sec", "45 sec") { it.tcpTimeout.backgroundTimeout }, + NetSetting("tcpTimeoutPerKb", "networkTCPTimeoutPerKb", 20_000, "0.01 sec", "0.02 sec") { it.tcpTimeoutPerKb }, + NetSetting("smpPingInterval", "NetworkSMPPingInterval", 600_000_000, "1200 sec", "600 sec") { it.smpPingInterval }, + NetSetting("smpPingCount", "NetworkSMPPingCount", 5, "3", "5") { it.smpPingCount.toLong() }, +) + +private const val PER_KB = "networkTCPTimeoutPerKb" +private const val PING_COUNT = "NetworkSMPPingCount" +private const val KEEP_ALIVE = "NetworkEnableKeepAlive" +private const val PROXY_MODE = "NetworkSMPProxyMode" +private const val PROXY_FALLBACK = "NetworkSMPProxyFallback" +private const val USE_SOCKS = "NetworkUseSocksProxy" +private const val HOST_MODE = "NetworkHostMode" +private const val REQUIRED_HOST_MODE = "NetworkRequiredHostMode" +private const val UPDATE_TITLE = "Update network settings?" +private const val ONION_REQUIRED_FOOTER = "Onion hosts will be required for connection." +private const val PROXY = "NetworkProxyHostPort" +private const val SESSION_MODE = "NetworkSessionMode" +private const val CUSTOM_PER_KB = 20_000L +private const val WRONG_PROXY_TITLE = "Error saving proxy" +private const val WRONG_PROXY_TEXT = "Make sure proxy configuration is correct." +private const val PROXY_HOST = "127.0.0.1" +private const val PROXY_PORT = "9150" +private val ALERT_CONFIRM = Ui.Alert.Confirm(Ui.AlertSource.Shared) +private val ALERT_DISMISS = Ui.Alert.Dismiss(Ui.AlertSource.Shared) + +class NetworkProxyTest : UiTest() { + @Test fun socks_enable_confirm() = uiTest { + val alice = app("alice") + saveNetworkConfigViaApi(coreNetworkConfig().copy(tcpTimeoutPerKb = CUSTOM_PER_KB)) + val def = NetCfg.defaults + val proxyDef = NetCfg.proxyDefaults + coreNetworkConfig().let { + check(it.socksProxy == null && it.tcpConnectTimeout == def.tcpConnectTimeout && it.tcpTimeout == def.tcpTimeout && it.rcvConcurrency == def.rcvConcurrency) { "network config before the test: $it" } + } + networkAndServers(alice) + toggleSocks("Use SOCKS proxy?") + poll(what = "the SOCKS proxy on with the proxy timeouts, keeping the changed per-KB timeout") { + storedSetting(USE_SOCKS) == "true" && coreNetworkConfig().let { + it.socksProxy != null && it.tcpConnectTimeout == proxyDef.tcpConnectTimeout && it.tcpTimeout == proxyDef.tcpTimeout && + it.rcvConcurrency == proxyDef.rcvConcurrency && it.tcpTimeoutPerKb == CUSTOM_PER_KB + } + } + toggleSocks("Use direct Internet connection?") + poll(what = "the SOCKS proxy off with the direct timeouts back, keeping the changed per-KB timeout") { + storedSetting(USE_SOCKS) == "false" && coreNetworkConfig().let { + it.socksProxy == null && it.tcpConnectTimeout == def.tcpConnectTimeout && it.tcpTimeout == def.tcpTimeout && + it.rcvConcurrency == def.rcvConcurrency && it.tcpTimeoutPerKb == CUSTOM_PER_KB + } + } + } + + @Test fun socks_wrongConfigAlert() = uiTest { + val alice = app("alice") + val network = networkAndServers(alice) + toggleSocks("Use SOCKS proxy?") + poll(what = "the SOCKS proxy on in the core") { storedSetting(USE_SOCKS) == "true" && coreNetworkConfig().socksProxy != null } + val socksBefore = coreNetworkConfig().socksProxy + val socks = network.socks() + check(editableText(Ui.Socks.Host) == "localhost" && editableText(Ui.Socks.Port) == "9050") { "SOCKS fields: `${editableText(Ui.Socks.Host)}`:`${editableText(Ui.Socks.Port)}`" } + replaceText(Ui.Socks.Host, "proxy.example") + socks.saveAndReconnect() + poll(what = "the `$WRONG_PROXY_TITLE` alert") { seesText(WRONG_PROXY_TITLE) && seesText(WRONG_PROXY_TEXT) && exists(ALERT_CONFIRM) } + click(ALERT_CONFIRM) + poll(what = "the alert to close") { !AlertManager.shared.hasAlertsShown() } + check(appPrefs.networkProxy.get() == NetworkProxy() && coreNetworkConfig().socksProxy == socksBefore && exists(Ui.Socks.Save)) { + "the rejected proxy was kept: stored ${appPrefs.networkProxy.get()}, core ${coreNetworkConfig().socksProxy}" + } + replaceText(Ui.Socks.Host, PROXY_HOST) + replaceText(Ui.Socks.Port, PROXY_PORT) + socks.saveAndReconnect() + poll(what = "the new proxy stored and applied, with SOCKS proxy settings still open") { + appPrefs.networkProxy.get().let { it.host == PROXY_HOST && it.port.toString() == PROXY_PORT } && + coreNetworkConfig().socksProxy?.contains("$PROXY_HOST:$PROXY_PORT") == true && exists(Ui.Socks.Save) && !AlertManager.shared.hasAlertsShown() + } + click(Ui.Socks.Reset) + poll(what = "the fields reset to localhost:9050") { editableText(Ui.Socks.Host) == "localhost" && editableText(Ui.Socks.Port) == "9050" } + check(coreNetworkConfig().socksProxy?.contains("$PROXY_HOST:$PROXY_PORT") == true && appPrefs.networkProxy.get().host == PROXY_HOST) { + "`Reset to defaults` saved before `Save and reconnect`: ${coreNetworkConfig().socksProxy}" + } + socks.saveAndReconnect() + poll(what = "the default proxy stored and applied") { appPrefs.networkProxy.get() == NetworkProxy() && coreNetworkConfig().socksProxy == socksBefore } + } + + @Test fun socks_unsavedAlert() = uiTest { + val alice = app("alice") + val storedBefore = storedSetting(PROXY) + val network = networkAndServers(alice) + var socks = network.socks() + check(editableText(Ui.Socks.Host) == "localhost" && editableText(Ui.Socks.Port) == "9050") { "SOCKS fields: `${editableText(Ui.Socks.Host)}`:`${editableText(Ui.Socks.Port)}`" } + for ((field, invalid) in listOf(Ui.Socks.Port to "70000", Ui.Socks.Host to "bad@host")) { + replaceText(field, invalid) + click(Ui.Socks.Save) + settle() + check(!AlertManager.shared.hasAlertsShown() && storedSetting(PROXY) == storedBefore) { "Save acted on the invalid `$invalid`" } + } + socks.back() + check(!AlertManager.shared.hasAlertsShown() && storedSetting(PROXY) == storedBefore) { "leaving with invalid fields asked to save or saved" } + socks = network.socks() + check(editableText(Ui.Socks.Host) == "localhost" && editableText(Ui.Socks.Port) == "9050") { "the invalid values were kept" } + replaceText(Ui.Socks.Port, PROXY_PORT) + goBack() + awaitUnsavedAlert() + click(ALERT_DISMISS) + poll(what = "`SOCKS proxy settings` closed by `Exit without saving`") { !exists(Ui.Socks.Save) && exists(Ui.Network.SaveServers) } + check(storedSetting(PROXY) == storedBefore) { "`Exit without saving` saved the port: ${storedSetting(PROXY)}" } + socks = network.socks() + check(editableText(Ui.Socks.Port) == "9050") { "the discarded port is shown: ${editableText(Ui.Socks.Port)}" } + replaceText(Ui.Socks.Port, PROXY_PORT) + goBack() + awaitUnsavedAlert() + click(ALERT_CONFIRM) + poll(what = "the port stored, `SOCKS proxy settings` still open") { + !AlertManager.shared.hasAlertsShown() && appPrefs.networkProxy.get().port.toString() == PROXY_PORT && exists(Ui.Socks.Save) && editableText(Ui.Socks.Port) == PROXY_PORT + } + check(coreNetworkConfig().socksProxy == null && storedSetting(USE_SOCKS) != "true") { "saving the proxy settings turned the SOCKS proxy on: ${coreNetworkConfig().socksProxy}" } + socks.back() + check(!AlertManager.shared.hasAlertsShown()) { "leaving after the save asked to save again" } + } + + @Test fun advNet_transportIsolation() = uiTest { + val alice = app("alice") + check(coreNetworkConfig().sessionMode == TransportSessionMode.Session && storedSetting(SESSION_MODE) != TransportSessionMode.Server.name) { "transport isolation before the test: ${coreNetworkConfig().sessionMode}" } + var network = networkAndServers(alice) + var adv = network.advanced() + check("App session" in textsOf(Ui.AdvancedNetwork.SessionMode)) { "`Transport isolation`: ${textsOf(Ui.AdvancedNetwork.SessionMode)}" } + pickSessionMode(TransportSessionMode.Server, listOf(TransportSessionMode.User to "Chat profile", TransportSessionMode.Session to "App session", TransportSessionMode.Server to "Server"), TransportSessionMode.Entity) + poll(what = "`Server` on `Transport isolation`") { "Server" in textsOf(Ui.AdvancedNetwork.SessionMode) } + adv.saveAndReconnect() + poll(what = "transport isolation by server stored and applied to the core") { + storedSetting(SESSION_MODE) == TransportSessionMode.Server.name && coreNetworkConfig().sessionMode == TransportSessionMode.Server + } + adv.back() + network.close() + click(Ui.Settings.Version) + click(Ui.Settings.DeveloperTools) + click(Ui.DevTools.ShowOptions) + poll(what = "developer tools stored as on") { storedSetting("DeveloperTools") == "true" } + closeDeveloperTools() + network = networkAndServers(alice) + adv = network.advanced() + pickSessionMode(TransportSessionMode.Entity, listOf(TransportSessionMode.Server to "Server", TransportSessionMode.Entity to "Connection"), null) + poll(what = "`Connection` on `Transport isolation`") { "Connection" in textsOf(Ui.AdvancedNetwork.SessionMode) } + adv.saveAndReconnect() + poll(what = "transport isolation by connection stored and applied to the core") { + storedSetting(SESSION_MODE) == TransportSessionMode.Entity.name && coreNetworkConfig().sessionMode == TransportSessionMode.Entity + } + } + + private fun UiScope.toggleSocks(title: String) { + click(Ui.Network.UseSocks) + poll(what = "the `$title` alert with `Confirm`") { seesText(title) && exists(ALERT_CONFIRM) && "Confirm" in textsOf(ALERT_CONFIRM) } + click(ALERT_CONFIRM) + poll(what = "the `$title` alert to close") { !AlertManager.shared.hasAlertsShown() } + } + + private fun UiScope.awaitUnsavedAlert() = + poll(what = "the `$UPDATE_TITLE` alert with `Save` and `Exit without saving`") { + seesText(UPDATE_TITLE) && "Save" in textsOf(ALERT_CONFIRM) && "Exit without saving" in textsOf(ALERT_DISMISS) + } + + private fun UiScope.pickSessionMode(mode: TransportSessionMode, shown: List>, hidden: TransportSessionMode?) { + val row = Ui.AdvancedNetwork.SessionMode + click(row) + poll(what = "the transport isolation options") { exists(row.option(mode)) && !exists(Ui.AdvancedNetwork.Save) } + for ((value, title) in shown) check(title in textsOf(row.option(value))) { "option $value is not `$title`: ${textsOf(row.option(value))}" } + if (hidden != null) check(!exists(row.option(hidden))) { "option $hidden is offered without developer tools" } + click(row.option(mode)) + goBack() + poll(what = "back on `Advanced settings` from the transport isolation options") { !exists(row.option(mode)) && exists(Ui.AdvancedNetwork.Save) } + } + + @Test fun socks_onionHostsOptions() = uiTest { + val alice = app("alice") + coreNetworkConfig().let { check(it.socksProxy == null && !it.requiredHostMode) { "network config before the test: $it" } } + check(storedSetting(REQUIRED_HOST_MODE) != "true") { "`.onion` hosts are stored as required before the test" } + val network = networkAndServers(alice) + var socks = network.socks() + check("No" in socks.onionHosts() && "When available" !in socks.onionHosts()) { "`Use .onion hosts` with the SOCKS proxy off: ${socks.onionHosts()}" } + click(Ui.Socks.OnionHosts) + settle() + check(exists(Ui.Socks.Save) && !exists(Ui.Socks.OnionHosts.option(OnionHosts.REQUIRED))) { "`Use .onion hosts` opened its options with the SOCKS proxy off" } + socks.back() + click(Ui.Network.UseSocks) + poll(what = "the `Use SOCKS proxy?` alert with `Confirm`") { seesText("Use SOCKS proxy?") && exists(ALERT_CONFIRM) && "Confirm" in textsOf(ALERT_CONFIRM) } + click(ALERT_CONFIRM) + poll(what = "the SOCKS proxy on in the stored settings and the core") { storedSetting(USE_SOCKS) == "true" && coreNetworkConfig().socksProxy != null } + socks = network.socks() + poll(what = "`Use .onion hosts` at `When available` with the SOCKS proxy on") { "When available" in socks.onionHosts() } + click(Ui.Socks.OnionHosts) + click(Ui.Socks.OnionHosts.option(OnionHosts.REQUIRED)) + poll(what = "`Required` with its footer") { "Required" in socks.onionHosts() && seesText(ONION_REQUIRED_FOOTER, substring = true) } + socks.saveAndReconnect() + poll(what = "`.onion` hosts required in the stored settings and the core") { + storedSetting(HOST_MODE) == HostMode.OnionViaSocks.name && storedSetting(REQUIRED_HOST_MODE) == "true" && + coreNetworkConfig().let { it.hostMode == HostMode.OnionViaSocks && it.requiredHostMode } + } + val after = restart() + poll(what = "chat running after the restart") { chatModel.chatRunning.value == true } + coreNetworkConfig().let { check(it.socksProxy != null && it.hostMode == HostMode.OnionViaSocks && it.requiredHostMode) { "network config after the restart: $it" } } + val again = networkAndServers(after).socks() + poll(what = "`Use .onion hosts` still `Required` after the restart") { "Required" in again.onionHosts() } + } + + @Test fun socks_trafficRoutedThroughProxy() = uiTest { + val alice = app("alice"); val bob = cli("bob") + SocksProxy.start() + connect(bob) + alice.chatList.open("bob").send("direct") + bob.expectsMessage("alice> direct", CONNECT_TIMEOUT_MS) + check(SocksProxy.connections().isEmpty()) { "the app used the SOCKS proxy before it was enabled: ${SocksProxy.connections()}" } + backToChatList() + val network = networkAndServers(alice) + network.socks().apply { saveProxy(SOCKS_HOST, SocksProxy.port); back() } + check(coreNetworkConfig().socksProxy == null && SocksProxy.connections().isEmpty()) { + "saving the proxy with `Use SOCKS proxy` off changed the network: ${coreNetworkConfig().socksProxy}, ${SocksProxy.connections()}" + } + network.enableSocks() + poll(what = "the core using the SOCKS proxy on port ${SocksProxy.port}") { coreNetworkConfig().socksProxy?.endsWith(":${SocksProxy.port}") == true } + val smp = localSmpServer() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "a connection to ${smp.hostnames.first()}:${smp.port} through the SOCKS proxy with random credentials") { + SocksProxy.connections().any { it.host == smp.hostnames.first() && it.port.toString() == smp.port && !it.username.isNullOrEmpty() } + } + network.close() + escape() + poll(what = "the settings to close to the chat list") { !exists(Ui.Settings.Network) && exists(Ui.ChatList.Search) } + alice.chatList.open("bob").send("via proxy") + bob.expectsMessage("alice> via proxy", CONNECT_TIMEOUT_MS) + } + + @Test fun advNet_privateRoutingMode() = uiTest { + val alice = app("alice") + coreNetworkConfig().let { check(it.smpProxyMode == SMPProxyMode.Always && it.smpProxyFallback == SMPProxyFallback.AllowProtected) { "network config before the test: $it" } } + check(storedSetting(PROXY_MODE) != SMPProxyMode.Unknown.name && storedSetting(PROXY_FALLBACK) != SMPProxyFallback.Prohibit.name) { "private routing is stored as changed before the test" } + val adv = networkAndServers(alice).advanced() + check("Always" in textsOf(Ui.AdvancedNetwork.ProxyMode) && "When IP hidden" in textsOf(Ui.AdvancedNetwork.ProxyFallback)) { + "private routing rows before the test: ${textsOf(Ui.AdvancedNetwork.ProxyMode)}, ${textsOf(Ui.AdvancedNetwork.ProxyFallback)}" + } + adv.pick(Ui.AdvancedNetwork.ProxyMode, SMPProxyMode.Never) + poll(what = "`Never` on `Private routing`") { "Never" in textsOf(Ui.AdvancedNetwork.ProxyMode) } + click(Ui.AdvancedNetwork.ProxyFallback) + settle() + check(exists(Ui.AdvancedNetwork.Save) && !exists(Ui.AdvancedNetwork.ProxyFallback.option(SMPProxyFallback.Prohibit))) { + "`Allow downgrade` opened its options with private routing off" + } + adv.pick(Ui.AdvancedNetwork.ProxyMode, SMPProxyMode.Unknown) + adv.pick(Ui.AdvancedNetwork.ProxyFallback, SMPProxyFallback.Prohibit) + poll(what = "`Unknown servers` and `No` on the private routing rows") { + "Unknown servers" in textsOf(Ui.AdvancedNetwork.ProxyMode) && "No" in textsOf(Ui.AdvancedNetwork.ProxyFallback) + } + adv.saveAndReconnect() + poll(what = "private routing stored and applied to the core") { + storedSetting(PROXY_MODE) == SMPProxyMode.Unknown.name && storedSetting(PROXY_FALLBACK) == SMPProxyFallback.Prohibit.name && + coreNetworkConfig().let { it.smpProxyMode == SMPProxyMode.Unknown && it.smpProxyFallback == SMPProxyFallback.Prohibit } + } + val after = restart() + poll(what = "chat running after the restart") { chatModel.chatRunning.value == true } + coreNetworkConfig().let { check(it.smpProxyMode == SMPProxyMode.Unknown && it.smpProxyFallback == SMPProxyFallback.Prohibit) { "network config after the restart: $it" } } + networkAndServers(after).advanced() + check("Unknown servers" in textsOf(Ui.AdvancedNetwork.ProxyMode) && "No" in textsOf(Ui.AdvancedNetwork.ProxyFallback)) { + "private routing rows after the restart: ${textsOf(Ui.AdvancedNetwork.ProxyMode)}, ${textsOf(Ui.AdvancedNetwork.ProxyFallback)}" + } + } + + @Test fun advNet_timeoutsSaved() = uiTest { + val alice = app("alice") + val before = coreNetworkConfig() + for (t in TIMEOUTS) check(t.core(before) != t.new && storedSetting(t.pref) != t.new.toString()) { "${t.name} is already ${t.new} before the test: $before" } + val adv = networkAndServers(alice).advanced() + for (t in TIMEOUTS) { + check(adv.value(t.name) == t.oldText) { "${t.name} shows `${adv.value(t.name)}` instead of `${t.oldText}`" } + adv.select(t.name, t.new) + poll(what = "${t.name} showing `${t.newText}`") { adv.value(t.name) == t.newText } + } + adv.saveAndReconnect() + poll(what = "all ${TIMEOUTS.size} values stored and applied to the core") { + TIMEOUTS.all { storedSetting(it.pref) == it.new.toString() } && coreNetworkConfig().let { c -> TIMEOUTS.all { it.core(c) == it.new } } + } + val after = restart() + poll(what = "chat running after the restart") { chatModel.chatRunning.value == true } + coreNetworkConfig().let { c -> check(TIMEOUTS.all { it.core(c) == it.new }) { "network config after the restart: $c" } } + val again = networkAndServers(after).advanced() + for (t in TIMEOUTS) check(again.value(t.name) == t.newText) { "${t.name} shows `${again.value(t.name)}` after the restart" } + } + + @Test fun advNet_keepAlive() = uiTest { + val alice = app("alice") + check(storedSetting(KEEP_ALIVE) != "false" && coreNetworkConfig().tcpKeepAlive == KeepAliveOpts.defaults) { "keep-alive before the test: ${coreNetworkConfig().tcpKeepAlive}" } + var adv = networkAndServers(alice).advanced() + check(adv.value("tcpKeepIdle") == "30 sec" && adv.value("tcpKeepCnt") == "4") { "TCP_KEEPIDLE `${adv.value("tcpKeepIdle")}`, TCP_KEEPCNT `${adv.value("tcpKeepCnt")}`" } + click(Ui.AdvancedNetwork.KeepAlive) + poll(what = "the TCP_KEEP* options turned into plain rows") { + !exists(Ui.AdvancedNetwork.Setting("tcpKeepIdle")) && !exists(Ui.AdvancedNetwork.Setting("tcpKeepCnt")) && seesText("TCP_KEEPIDLE") && seesText("TCP_KEEPCNT") + } + adv.saveAndReconnect() + poll(what = "keep-alive stored as off") { storedSetting(KEEP_ALIVE) == "false" } + val after = restart() + poll(what = "chat running after the restart") { chatModel.chatRunning.value == true } + adv = networkAndServers(after).advanced() + check(exists(Ui.AdvancedNetwork.KeepAlive) && seesText("TCP_KEEPIDLE") && !exists(Ui.AdvancedNetwork.Setting("tcpKeepIdle"))) { "keep-alive is on again after the restart" } + click(Ui.AdvancedNetwork.KeepAlive) + adv.select("tcpKeepIdle", 60) + adv.select("tcpKeepCnt", 8) + poll(what = "TCP_KEEPIDLE `60 sec` and TCP_KEEPCNT `8`") { adv.value("tcpKeepIdle") == "60 sec" && adv.value("tcpKeepCnt") == "8" } + adv.saveAndReconnect() + poll(what = "keep-alive stored as on with the new values and applied to the core") { + storedSetting(KEEP_ALIVE) == "true" && storedSetting("NetworkTCPKeepIdle") == "60" && storedSetting("NetworkTCPKeepCnt") == "8" && + coreNetworkConfig().tcpKeepAlive == KeepAliveOpts(keepIdle = 60, keepIntvl = KeepAliveOpts.defaults.keepIntvl, keepCnt = 8) + } + } + + @Test fun advNet_resetToDefaults() = uiTest { + val alice = app("alice") + saveNetworkConfigViaApi(NetCfg.defaults.copy(tcpTimeoutPerKb = 20_000, smpPingCount = 5)) + val adv = networkAndServers(alice).advanced() + check(adv.value("tcpTimeoutPerKb") == "0.02 sec" && adv.value("smpPingCount") == "5") { + "the saved values are not shown: `${adv.value("tcpTimeoutPerKb")}`, `${adv.value("smpPingCount")}`" + } + click(Ui.AdvancedNetwork.Reset) + poll(what = "the defaults shown after `Reset to defaults`") { + adv.value("tcpTimeoutPerKb") == "0.01 sec" && adv.value("smpPingCount") == "3" && adv.value("tcpConnectTimeout") == "15 sec" + } + check(storedSetting(PER_KB) == "20000" && storedSetting(PING_COUNT) == "5" && coreNetworkConfig().let { it.tcpTimeoutPerKb == 20_000L && it.smpPingCount == 5 }) { + "`Reset to defaults` applied the defaults before `Save and reconnect`: ${coreNetworkConfig()}" + } + adv.saveAndReconnect() + poll(what = "the defaults stored and applied to the core") { + storedSetting(PER_KB) == "10000" && storedSetting(PING_COUNT) == "3" && coreNetworkConfig().let { it.tcpTimeoutPerKb == 10_000L && it.smpPingCount == 3 } + } + } + + @Test fun advNet_unsavedChangesAlert() = uiTest { + val alice = app("alice") + val network = networkAndServers(alice) + network.advanced().back() + check(!AlertManager.shared.hasAlertsShown()) { "leaving `Advanced settings` without changes showed an alert" } + var adv = network.advanced() + adv.select("smpPingCount", 5) + goBack() + poll(what = "the `$UPDATE_TITLE` alert with `Save and reconnect` and `Exit without saving`") { + seesText(UPDATE_TITLE) && exists(ALERT_CONFIRM) && "Save and reconnect" in textsOf(ALERT_CONFIRM) && "Exit without saving" in textsOf(ALERT_DISMISS) + } + click(ALERT_DISMISS) + adv.awaitClosed() + check(storedSetting(PING_COUNT) != "5" && coreNetworkConfig().smpPingCount == 3) { "`Exit without saving` saved the change: ${coreNetworkConfig()}" } + adv = network.advanced() + check(adv.value("smpPingCount") == "3") { "PING count shows `${adv.value("smpPingCount")}` after `Exit without saving`" } + adv.select("smpPingCount", 8) + goBack() + poll(what = "the `$UPDATE_TITLE` alert") { seesText(UPDATE_TITLE) && exists(ALERT_CONFIRM) } + click(ALERT_CONFIRM) + adv.awaitClosed() + poll(what = "PING count 8 stored and applied to the core") { storedSetting(PING_COUNT) == "8" && coreNetworkConfig().smpPingCount == 8 } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/NetworkServersTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/NetworkServersTest.kt new file mode 100644 index 0000000000..030f8fcb2d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/NetworkServersTest.kt @@ -0,0 +1,272 @@ +package chat.simplex.app.ui.settings + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.TestServers +import chat.simplex.app.ui.harness.UNREACHABLE_HOST +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.addCustomSmpServerViaApi +import chat.simplex.app.ui.harness.coreOperators +import chat.simplex.app.ui.harness.customServers +import chat.simplex.app.ui.harness.customSmpServer +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.hostOf +import chat.simplex.app.ui.harness.networkAndServers +import chat.simplex.app.ui.harness.openNetworkFromSettings +import chat.simplex.app.ui.harness.unreachableSmpServer +import chat.simplex.common.model.ConditionsAcceptance +import chat.simplex.common.model.OperatorTag +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.views.helpers.AlertManager +import org.junit.Test + +private const val TEST_FAILED = "Server test failed!" +private const val TEST_SERVERS = "Test servers" +private const val SOME_FAILED = "Some servers failed the test:" +private const val NO_MESSAGE_SERVERS = "No message servers." +private const val ACCEPTED_ON = "Conditions accepted on:" +private const val CONDITIONS_HEADING = "SimpleX Chat Operators Privacy Policy and Conditions of Use" +private const val INVALID_ADDRESS = "not a server address" +private val CONFIRM = Ui.Alert.Confirm(Ui.AlertSource.Shared) +private val DISMISS = Ui.Alert.Dismiss(Ui.AlertSource.Shared) + +private fun listsServer(alertText: String, host: String): Boolean = Regex("\\s${Regex.escape(host)}: ").containsMatchIn(alertText) + +class NetworkServersTest : UiTest() { + @Test fun operators_enableDisableOperator() = uiTest { + val alice = app("alice") + val simplex = coreOperators().single { it.operatorTag == OperatorTag.SimpleX } + val flux = coreOperators().single { it.operatorTag == OperatorTag.Flux } + check(!simplex.enabled && !flux.enabled && simplex.conditionsAcceptance.let { it is ConditionsAcceptance.Required && it.deadline == null }) { + "operators before the test: $simplex, $flux" + } + val network = networkAndServers(alice) + poll(what = "the `Network & servers` sections") { + seesText("Preset servers") && exists(Ui.Network.Operator(flux.operatorId)) && seesText("Messages and files") && seesText("Your servers") && + seesText("Use SOCKS proxy") && seesText("SOCKS proxy settings") && seesText("Advanced network settings") && seesText("Save servers") && + seesText("Calls") && seesText("WebRTC ICE servers") + } + check(!network.edited(Ui.Network.Operator(simplex.operatorId))) { "the SimpleX Chat row is marked as edited before the test" } + var operator = network.operator(simplex.operatorId) + poll(what = "`${simplex.tradeName} servers` with the operator off") { seesText("${simplex.tradeName} servers") } + check(!seesText(TEST_SERVERS) && !seesText(ACCEPTED_ON, substring = true)) { "the disabled operator shows its servers or accepted conditions" } + operator.info() + simplex.legalName?.let { name -> check(seesText(name)) { "`Network operator` without `$name`: ${texts().take(30)}" } } + operator.closeInfo() + operator.toggleUse() + poll(what = "`Use ${simplex.tradeName}` with the conditions text and `Accept conditions`") { + seesText("Use ${simplex.tradeName}") && exists(Ui.Operator.AcceptConditions) && seesText(CONDITIONS_HEADING, substring = true) + } + click(Ui.Operator.AcceptConditions) + poll(what = "the operator on with its servers and the conditions accepted") { + !exists(Ui.Operator.AcceptConditions) && exists(Ui.Operator.Use) && seesText(TEST_SERVERS) && seesText(ACCEPTED_ON, substring = true) + } + coreOperators().single { it.operatorId == simplex.operatorId }.let { + check(it.conditionsAcceptance.conditionsAccepted && !it.enabled) { "after `Accept conditions`, before `Save servers`: $it" } + } + operator.back() + poll(what = "the SimpleX Chat row marked as edited") { network.edited(Ui.Network.Operator(simplex.operatorId)) } + network.save(Ui.Network.Operator(simplex.operatorId)) + coreOperators().let { ops -> + check(ops.single { it.operatorId == simplex.operatorId }.enabled && !ops.single { it.operatorId == flux.operatorId }.enabled) { "operators after saving: $ops" } + } + operator = network.operator(simplex.operatorId) + operator.toggleUse() + poll(what = "the operator off without the conditions sheet") { exists(Ui.Operator.Use) && !seesText(TEST_SERVERS) && !exists(Ui.Operator.AcceptConditions) } + operator.back() + poll(what = "the SimpleX Chat row marked as edited again") { network.edited(Ui.Network.Operator(simplex.operatorId)) } + network.save(Ui.Network.Operator(simplex.operatorId)) + coreOperators().single { it.operatorId == simplex.operatorId }.let { + check(!it.enabled && it.conditionsAcceptance.conditionsAccepted) { "the operator after turning it off and saving: $it" } + } + } + + @Test fun yourServers_testServers_allPass() = uiTest { + val alice = app("alice") + val smp = customServers().smpServers.single { it.enabled } + val xftp = customServers().xftpServers.single { it.enabled } + check(smp.tested == null && xftp.tested == null) { "the local servers are marked as tested before the test: $smp, $xftp" } + val network = networkAndServers(alice) + check(!network.edited(Ui.Network.YourServers)) { "`Your servers` is marked as edited before the test" } + val servers = network.yourServers() + poll(what = "the local SMP and XFTP servers under `Message servers` and `Media & file servers`") { + seesText("Message servers") && seesText("Media & file servers") && + hostOf(smp.server) in textsOf(Ui.Servers.Server(smp.server)) && hostOf(xftp.server) in textsOf(Ui.Servers.Server(xftp.server)) + } + servers.testAll() + check(!AlertManager.shared.hasAlertsShown() && !seesText(TEST_FAILED)) { "a local server failed the test: ${texts().take(40)}" } + servers.back() + poll(what = "`Your servers` marked as edited with the test results") { network.edited(Ui.Network.YourServers) } + network.save(Ui.Network.YourServers) + customServers().let { saved -> + check(saved.smpServers.single { it.server == smp.server }.tested == true && saved.xftpServers.single { it.server == xftp.server }.tested == true) { + "test results in the core after saving: $saved" + } + } + } + + @Test fun yourServers_testServers_someFail() = uiTest { + val alice = app("alice") + val smp = customServers().smpServers.single { it.enabled } + val unreachable = unreachableSmpServer() + val network = networkAndServers(alice) + val servers = network.yourServers() + val new = servers.add() + new.enterAddress(unreachable) + new.test() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `$TEST_FAILED` alert for the new server") { seesText(TEST_FAILED) && exists(CONFIRM) } + check(!seesText(SOME_FAILED, substring = true)) { "testing one server showed the all-servers failure text" } + click(CONFIRM) + poll(what = "the alert to close") { !AlertManager.shared.hasAlertsShown() } + new.toggleUse() + new.back() + poll(what = "the new server listed in `Your servers`") { exists(Ui.Servers.Server(unreachable)) && UNREACHABLE_HOST in textsOf(Ui.Servers.Server(unreachable)) } + servers.testAll() + var failed: String? = null + poll(what = "the `$TEST_FAILED` alert listing the failed servers") { + failed = texts().firstOrNull { it.startsWith(SOME_FAILED) } + seesText(TEST_FAILED) && failed != null + } + check(listsServer(failed!!, UNREACHABLE_HOST) && !listsServer(failed!!, hostOf(smp.server)!!)) { "failed servers in the alert: $failed" } + click(CONFIRM) + poll(what = "the alert to close") { !AlertManager.shared.hasAlertsShown() } + servers.back() + network.save(Ui.Network.YourServers) + val saved = customServers().smpServers + check(customSmpServer(UNREACHABLE_HOST)?.let { it.enabled && it.tested == false } == true && saved.single { it.server == smp.server }.tested == true) { + "message servers in the core after saving: $saved" + } + } + + @Test fun yourServers_addSmpManually() = uiTest { + val alice = app("alice") + val smp2 = TestServers.start("smp2") + TestServers.stop("smp2") + val host = hostOf(smp2)!! + check(customSmpServer(host) == null) { "smp2 is in the core before it is added: ${customServers().smpServers}" } + var network = networkAndServers(alice) + val servers = network.yourServers() + val new = servers.add() + new.enterAddress(smp2) + new.test() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `$TEST_FAILED` alert while smp2 is stopped") { seesText(TEST_FAILED) && exists(CONFIRM) } + click(CONFIRM) + poll(what = "the alert to close") { !AlertManager.shared.hasAlertsShown() } + TestServers.start("smp2") + new.test() + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the test of the running smp2 to finish") { UiIdle.inFlight().isEmpty() } + check(!AlertManager.shared.hasAlertsShown() && !seesText(TEST_FAILED)) { "the running smp2 failed the test: ${texts().take(40)}" } + new.toggleUse() + new.back() + poll(what = "smp2 listed in `Your servers`") { exists(Ui.Servers.Server(smp2)) && host in textsOf(Ui.Servers.Server(smp2)) } + servers.back() + poll(what = "`Your servers` marked as edited") { network.edited(Ui.Network.YourServers) } + network.save(Ui.Network.YourServers) + val saved = customSmpServer(host) ?: throw AssertionError("smp2 is not in the core after saving: ${customServers().smpServers}") + check(saved.enabled && saved.tested == true) { "smp2 in the core after saving: $saved" } + network.close() + network = openNetworkFromSettings() + network.yourServers() + poll(what = "smp2 listed after reopening `Your servers`") { exists(Ui.Servers.Server(saved.server)) && host in textsOf(Ui.Servers.Server(saved.server)) } + } + + @Test fun newServer_invalidAddress() = uiTest { + val alice = app("alice") + val before = customServers() + val network = networkAndServers(alice) + val servers = network.yourServers() + val new = servers.add() + new.enterAddress(INVALID_ADDRESS) + new.back() + poll(what = "the `Invalid server address!` alert") { seesText("Invalid server address!") && seesText("Check server address and try again.") && exists(CONFIRM) } + click(CONFIRM) + poll(what = "the alert to close") { !AlertManager.shared.hasAlertsShown() } + check(exists(Ui.Servers.Server(before.smpServers.single { it.enabled }.server)) && !exists(Ui.Servers.Server(INVALID_ADDRESS))) { + "`Your servers` after the invalid address: ${texts().take(40)}" + } + servers.back() + check(!network.edited(Ui.Network.YourServers)) { "`Your servers` is marked as edited after the invalid address" } + check(customServers() == before) { "the core servers changed: ${customServers()}" } + } + + @Test fun serverView_toggleUseAndSave() = uiTest { + val alice = app("alice") + val smp = customServers().smpServers.single { it.enabled } + val second = addCustomSmpServerViaApi(unreachableSmpServer()) + var network = networkAndServers(alice) + var servers = network.yourServers() + servers.open(second).apply { toggleUse(); back() } + servers.back() + poll(what = "`Your servers` marked as edited") { network.edited(Ui.Network.YourServers) } + network.save(Ui.Network.YourServers) + check(customSmpServer(UNREACHABLE_HOST)?.enabled == false && customServers().smpServers.single { it.server == smp.server }.enabled) { + "message servers after turning the second one off: ${customServers().smpServers}" + } + val after = restart() + poll(what = "chat running after the restart") { chatModel.chatRunning.value == true } + check(customSmpServer(UNREACHABLE_HOST)?.enabled == false) { "the second server is on again after the restart: ${customServers().smpServers}" } + network = networkAndServers(after) + servers = network.yourServers() + check(!seesText(NO_MESSAGE_SERVERS)) { "`$NO_MESSAGE_SERVERS` is shown with the local server on" } + servers.open(smp.server).apply { toggleUse(); back() } + poll(what = "`$NO_MESSAGE_SERVERS` in `Your servers`") { seesText(NO_MESSAGE_SERVERS) } + servers.back() + poll(what = "`$NO_MESSAGE_SERVERS` on `Network & servers`") { seesText(NO_MESSAGE_SERVERS) && network.edited(Ui.Network.YourServers) } + click(Ui.Network.SaveServers) + poll(what = "app work to finish after `Save servers`") { UiIdle.inFlight().isEmpty() } + check(!AlertManager.shared.hasAlertsShown() && network.edited(Ui.Network.YourServers)) { "`Save servers` acted with no message servers: ${texts().take(40)}" } + check(customServers().smpServers.single { it.server == smp.server }.enabled) { "the local server was turned off in the core" } + servers = network.yourServers() + servers.open(smp.server).apply { toggleUse(); back() } + poll(what = "`$NO_MESSAGE_SERVERS` gone with the local server on") { !seesText(NO_MESSAGE_SERVERS) && exists(Ui.Servers.Add) } + servers.open(second).apply { toggleUse(); back() } + servers.back() + poll(what = "`Your servers` marked as edited") { network.edited(Ui.Network.YourServers) } + network.save(Ui.Network.YourServers) + check(customSmpServer(UNREACHABLE_HOST)?.enabled == true) { "the second server after turning it on again: ${customServers().smpServers}" } + } + + @Test fun serverView_deleteServer() = uiTest { + val alice = app("alice") + val smp = customServers().smpServers.single { it.enabled && hostOf(it.server) != UNREACHABLE_HOST } + val second = addCustomSmpServerViaApi(unreachableSmpServer()) + val network = networkAndServers(alice) + val servers = network.yourServers() + poll(what = "both message servers listed") { exists(Ui.Servers.Server(second)) && exists(Ui.Servers.Server(smp.server)) } + servers.open(second).delete() + poll(what = "the deleted server gone from `Your servers` with the local server still listed") { + !exists(Ui.Servers.Server(second)) && exists(Ui.Servers.Server(smp.server)) + } + servers.back() + poll(what = "`Your servers` marked as edited") { network.edited(Ui.Network.YourServers) } + network.save(Ui.Network.YourServers) + check(customSmpServer(UNREACHABLE_HOST) == null && customServers().smpServers.any { it.server == smp.server && it.enabled }) { + "message servers after deleting the second one: ${customServers().smpServers}" + } + } + + @Test fun network_unsavedServersAlert_save() = uiTest { + val alice = app("alice") + val second = addCustomSmpServerViaApi(unreachableSmpServer()) + networkAndServers(alice).close() + check(!AlertManager.shared.hasAlertsShown()) { "leaving `Network & servers` without changes showed an alert" } + var network = openNetworkFromSettings() + network.yourServers().apply { open(second).apply { toggleUse(); back() }; back() } + goBack() + poll(what = "the `Save servers?` alert with `Save` and `Exit without saving`") { + seesText("Save servers?") && exists(CONFIRM) && "Save" in textsOf(CONFIRM) && "Exit without saving" in textsOf(DISMISS) + } + click(DISMISS) + network.awaitClosed() + check(customSmpServer(UNREACHABLE_HOST)?.enabled == true) { "`Exit without saving` saved the change: ${customServers().smpServers}" } + network = openNetworkFromSettings() + check(!network.edited(Ui.Network.YourServers)) { "the discarded change is still pending" } + network.yourServers().apply { open(second).apply { toggleUse(); back() }; back() } + goBack() + poll(what = "the `Save servers?` alert") { seesText("Save servers?") && exists(CONFIRM) } + click(CONFIRM) + network.awaitClosed() + poll(what = "the change saved in the core") { customSmpServer(UNREACHABLE_HOST)?.enabled == false } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/PrivacyTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/PrivacyTest.kt index de87eb162d..22478cfa75 100644 --- a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/PrivacyTest.kt +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/PrivacyTest.kt @@ -1,15 +1,58 @@ package chat.simplex.app.ui.settings import chat.simplex.app.ui.harness.App +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.CliPeer import chat.simplex.app.ui.harness.UiScope import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.backToChatList import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.groupId +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.itemStatus +import chat.simplex.app.ui.harness.linkGroupWithDirectMessages +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.switchAwayFromChat +import chat.simplex.app.ui.harness.visitNotesAndBack +import chat.simplex.app.ui.harness.yourPrivacy +import chat.simplex.common.model.CIFile +import chat.simplex.common.model.CIFileStatus +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.GroupMemberStatus +import chat.simplex.common.model.MsgContent +import chat.simplex.common.model.User +import chat.simplex.common.model.UserMsgReceiptSettings +import chat.simplex.common.platform.appFilesDir +import chat.simplex.common.platform.chatController import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.getAppFilePath import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.views.helpers.AlertManager +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals import org.junit.Test +import java.io.File private const val SHOW_PREVIEWS = "PrivacyShowChatPreviews" private const val MESSAGE = "hello from bob" +private const val ACCEPT_IMAGES = "PrivacyAcceptImages" +private const val BLUR_RADIUS = "PrivacyMediaBlurRadius" +private const val RECEIPTS_SET = "PrivacyDeliveryReceiptsSet" +private const val ASKED_TO_RECEIVE = "Asked to receive the image" +private const val FIXTURE_JPG = "tests/fixtures/test.jpg" +private val BLUR_OPTIONS = listOf(0 to "Off", 12 to "Soft", 24 to "Medium", 48 to "Strong") +private const val SAVE_DRAFT = "PrivacySaveLastDraft" +private const val KEPT_DRAFT = "draft kept for bob" +private const val LOST_DRAFT = "draft not kept" +private const val ENCRYPT_FILES = "PrivacyEncryptLocalFiles" +private const val MEMBER_HI = "hi from the group" +private const val NOTES = "Private notes" +private const val RECEIPTS_TITLE = "Delivery receipts!" +private val JPEG_MAGIC = listOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte()) +private val CONFIRM = Ui.Alert.Confirm(Ui.AlertSource.Shared) +private val DISMISS = Ui.Alert.Dismiss(Ui.AlertSource.Shared) class PrivacyTest : UiTest() { @Test fun morePrivacy_showLastMessages() = uiTest { @@ -31,6 +74,270 @@ class PrivacyTest : UiTest() { poll(what = "bob's message on the row again") { MESSAGE in after.chatList.rowTexts("bob") } } + @Test fun privacy_autoAcceptImagesOff() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + check(storedSetting(ACCEPT_IMAGES) != "false") { "Auto-accept images is stored as off before it is turned off" } + val privacy = yourPrivacy(alice) + click(Ui.Privacy.AcceptImages) + poll(what = "Auto-accept images stored as off") { storedSetting(ACCEPT_IMAGES) == "false" && !appPrefs.privacyAcceptImages.get() } + privacy.close() + bob.sendsImage(1) + val chat = alice.chatList.open("bob") + val first = chat.itemId("bob's first image") { it.content.msgContent is MsgContent.MCImage } + poll(what = "the app to finish handling bob's first image") { UiIdle.inFlight().isEmpty() } + check(fileOf(first)?.fileStatus is CIFileStatus.RcvInvitation && ASKED_TO_RECEIVE in descriptionsOf(Ui.Chat.Item(first))) { + "the first image was not left to be received: ${fileOf(first)?.fileStatus}, ${descriptionsOf(Ui.Chat.Item(first))}" + } + check(appFilesDir.listFiles().isNullOrEmpty()) { "files were saved with Auto-accept images off: ${appFilesDir.listFiles()?.map { it.name }}" } + backToChatList() + val again = yourPrivacy(alice) + click(Ui.Privacy.AcceptImages) + poll(what = "Auto-accept images stored as on") { storedSetting(ACCEPT_IMAGES) == "true" && appPrefs.privacyAcceptImages.get() } + again.close() + bob.sendsImage(2) + val chatAgain = alice.chatList.open("bob") + val second = chatAgain.itemId("bob's second image") { it.content.msgContent is MsgContent.MCImage && it.id != first } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's second image to be received without a tap") { fileOf(second)?.fileStatus is CIFileStatus.RcvComplete } + check(fileOf(first)?.fileStatus is CIFileStatus.RcvInvitation) { "turning Auto-accept images on received the earlier image: ${fileOf(first)?.fileStatus}" } + } + + @Test fun privacy_blurMediaOptions() = uiTest { + val alice = app("alice") + val privacy = yourPrivacy(alice) + check(textOf(Ui.Privacy.BlurMedia) == "Off" && storedSetting(BLUR_RADIUS) != "24") { "blur before the change: ${textOf(Ui.Privacy.BlurMedia)}, stored ${storedSetting(BLUR_RADIUS)}" } + click(Ui.Privacy.BlurMedia) + for ((radius, name) in BLUR_OPTIONS) { + check(name in textsOf(Ui.Privacy.BlurMedia.option(radius))) { "blur option $radius is not `$name`: ${textsOf(Ui.Privacy.BlurMedia.option(radius))}" } + } + click(Ui.Privacy.BlurMedia.option(24)) + poll(what = "Medium blur shown and stored") { textOf(Ui.Privacy.BlurMedia) == "Medium" && storedSetting(BLUR_RADIUS) == "24" } + privacy.close() + val after = restart() + yourPrivacy(after) + assertEquals("Medium", textOf(Ui.Privacy.BlurMedia)) + assertEquals(24, appPrefs.privacyMediaBlurRadius.get()) + } + + @Test fun receipts_contactsAndGroupsToggles_peerReceipts() = uiTest { + val alice = app("alice"); val bob = cli("bob") + clearReceiptsChoiceViaApi() + connect(bob) + ownGroup(alice, "team", bob) + backToChatList() + val direct = "@${bob.contactId("alice")}" + val team = "#${bob.groupId("team")}" + bob sends "@alice d0" + bob sends "#team g0" + awaitReceipt(bob, direct, "d0") + awaitReceipt(bob, team, "g0") + check(storedSetting(RECEIPTS_SET) != "true") { "the receipts choice is stored before any toggle" } + yourPrivacy(alice).more() + click(Ui.Privacy.ReceiptsContacts) + poll(what = "contact receipts off and the receipts choice stored") { !user().sendRcptsContacts && user().sendRcptsSmallGroups && storedSetting(RECEIPTS_SET) == "true" } + check(!AlertManager.shared.hasAlertsShown()) { "turning contact receipts off without overrides asked for confirmation" } + bob sends "@alice d1" + bob sends "#team g1" + awaitReceived(alice, "bob", "d1") + awaitReceived(alice, "team", "g1") + click(Ui.Privacy.ReceiptsGroups) + poll(what = "group receipts off") { !user().sendRcptsSmallGroups } + click(Ui.Privacy.ReceiptsContacts) + poll(what = "contact receipts on again") { user().sendRcptsContacts } + bob sends "@alice d2" + bob sends "#team g2" + awaitReceived(alice, "bob", "d2") + awaitReceived(alice, "team", "g2") + click(Ui.Privacy.ReceiptsGroups) + poll(what = "group receipts on again") { user().sendRcptsSmallGroups } + bob sends "#team g3" + awaitReceipt(bob, direct, "d2") + awaitReceipt(bob, team, "g3") + assertEquals("d1 sent while contact receipts were off", "sndSent", bob.itemStatus(direct, "d1")) + assertEquals("g1 sent while only contact receipts were off", "sndRcvd", bob.itemStatus(team, "g1")) + assertEquals("g2 sent while group receipts were off", "sndSent", bob.itemStatus(team, "g2")) + } + + @Test fun privacy_autoAcceptGroupInvitations() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "club", "alice") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's invitation to club pending for alice") { membershipStatus("club") == GroupMemberStatus.MemInvited } + check(!user().autoAcceptGroupInvitations && !coreUser().autoAcceptGroupInvitations) { "group invitations are auto-accepted before the setting is turned on" } + val privacy = yourPrivacy(alice) + click(Ui.Privacy.AutoAcceptGroupInvitations) + poll(what = "group invitations auto-accepted in the model and the core") { user().autoAcceptGroupInvitations && coreUser().autoAcceptGroupInvitations } + privacy.close() + hostGroup(bob, "team", "alice") + bob.eventually("#team: alice joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice connected to team without accepting the invitation") { membershipStatus("team") == GroupMemberStatus.MemConnected } + check(membershipStatus("club") == GroupMemberStatus.MemInvited) { "the invitation received before the setting was turned on was accepted: ${membershipStatus("club")}" } + } + + @Test fun privacy_autoAcceptContactRequestsInGroups() = uiTest { + val alice = app("alice"); val cath = cli("cath") + linkGroupWithDirectMessages(alice, "team", cath).close() + backToChatList() + check(!user().autoAcceptMemberContacts && !coreUser().autoAcceptMemberContacts && directChat("cath") == null) { "member contacts are auto-accepted, or cath is a contact, before the setting is turned on" } + val privacy = yourPrivacy(alice) + click(Ui.Privacy.AutoAcceptMemberContacts) + poll(what = "member contact requests auto-accepted in the model and the core") { user().autoAcceptMemberContacts && coreUser().autoAcceptMemberContacts } + privacy.close() + cath sends "@#team alice $MEMBER_HI" + cath.eventuallyAll( + lines = listOf( + "member #team alice does not have direct connection, creating", + "contact for member #team alice is created", + "sent invitation to connect directly to member #team alice", + ), + messages = listOf("@alice $MEMBER_HI"), + timeoutMs = CONNECT_TIMEOUT_MS, + ) + cath.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice's direct chat with cath connected without accepting") { directChat("cath")?.chatInfo?.ready == true } + alice.chatList.open("cath").itemId(MEMBER_HI) + poll(what = "the message field without `Accept` or `Reject`") { exists(Ui.Chat.Message) && !exists(Ui.Chat.RequestAccept) && !exists(Ui.Chat.RequestReject) } + } + + @Test fun morePrivacy_messageDraftDisabledClears() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val bobId = alice.chatList.chatId("bob") + alice.chatList.open("bob") + typeInto(Ui.Chat.Message, KEPT_DRAFT) + visitNotesAndBack(alice, "bob") + poll(what = "the draft restored with `Message draft` on") { editableText(Ui.Chat.Message) == KEPT_DRAFT } + switchAwayFromChat(alice) + poll(what = "the draft on bob's row") { alice.chatList.rowTexts("bob").any { it.endsWith(" $KEPT_DRAFT") } } + check(storedSetting(SAVE_DRAFT) != "false") { "`Message draft` is stored as off before it is turned off" } + val privacy = yourPrivacy(alice).more() + click(Ui.Privacy.SaveDraft) + poll(what = "`Message draft` stored as off") { storedSetting(SAVE_DRAFT) == "false" } + privacy.closeMore() + privacy.close() + poll(what = "bob's row without the draft") { alice.chatList.rowTexts("bob").let { "bob" in it && it.none { t -> t.endsWith(" $KEPT_DRAFT") } } } + alice.chatList.open("bob") + poll(what = "bob's field empty after turning `Message draft` off") { chatModel.chatId.value == bobId && editableText(Ui.Chat.Message).isNullOrEmpty() } + typeInto(Ui.Chat.Message, LOST_DRAFT) + visitNotesAndBack(alice, "bob") + poll(what = "bob's field empty after leaving the chat") { chatModel.chatId.value == bobId && editableText(Ui.Chat.Message).isNullOrEmpty() } + switchAwayFromChat(alice) + check(alice.chatList.rowTexts("bob").none { it.endsWith(" $LOST_DRAFT") }) { "a draft was saved with `Message draft` off: ${alice.chatList.rowTexts("bob")}" } + } + + @Test fun morePrivacy_encryptLocalFilesOff() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + check(storedSetting(ENCRYPT_FILES) != "false") { "`Encrypt local files` is stored as off before it is turned off" } + bob.sendsImage(1) + val chat = alice.chatList.open("bob") + val first = chat.itemId("bob's first image") { it.content.msgContent is MsgContent.MCImage } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's first image received") { fileOf(first)?.fileStatus is CIFileStatus.RcvComplete } + fileOf(first)!!.let { check(it.fileSource?.cryptoArgs != null && !storedAsPlainJpeg(it)) { "the first image is not stored encrypted: ${it.fileSource}" } } + backToChatList() + val privacy = yourPrivacy(alice).more() + click(Ui.Privacy.EncryptLocalFiles) + poll(what = "`Encrypt local files` stored as off and applied") { storedSetting(ENCRYPT_FILES) == "false" && UiIdle.inFlight().isEmpty() } + privacy.closeMore() + privacy.close() + bob.sendsImage(2) + val again = alice.chatList.open("bob") + val second = again.itemId("bob's second image") { it.content.msgContent is MsgContent.MCImage && it.id != first } + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's second image received") { fileOf(second)?.fileStatus is CIFileStatus.RcvComplete } + fileOf(second)!!.let { check(it.fileSource?.cryptoArgs == null && storedAsPlainJpeg(it)) { "the second image is not stored as the plain JPEG: ${it.fileSource}" } } + fileOf(first)!!.let { check(it.fileSource?.cryptoArgs != null && !storedAsPlainJpeg(it)) { "turning encryption off changed the first image: ${it.fileSource}" } } + } + + @Test fun deliveryReceiptsStartupView_enable() = uiTest { + app("alice") + turnOffContactReceiptsViaApi() + clearReceiptsChoiceViaApi() + restart() + awaitReceiptsView() + check(!coreUser().sendRcptsContacts && storedSetting(RECEIPTS_SET) == "false") { "contact receipts are on, or the choice is stored, before Enable" } + click(Ui.DeliveryReceipts.Enable) + poll(what = "contact receipts on in the core, the choice stored and the chat list shown") { + coreUser().sendRcptsContacts && storedSetting(RECEIPTS_SET) == "true" && !exists(Ui.DeliveryReceipts.Enable) && exists(Ui.ChatList.Search) + } + val after = restart() + poll(what = "the chat list after the restart") { after.chatList.shown(NOTES) } + check(!exists(Ui.DeliveryReceipts.Enable) && !seesText(RECEIPTS_TITLE)) { "`$RECEIPTS_TITLE` shown again after Enable" } + } + + @Test fun deliveryReceiptsStartupView_skip() = uiTest { + app("alice") + turnOffContactReceiptsViaApi() + clearReceiptsChoiceViaApi() + restart() + awaitReceiptsView() + click(Ui.DeliveryReceipts.Skip) + awaitReceiptsDisabledAlert() + click(CONFIRM) + poll(what = "the chat list after OK") { !AlertManager.shared.hasAlertsShown() && !exists(Ui.DeliveryReceipts.Enable) && exists(Ui.ChatList.Search) } + check(storedSetting(RECEIPTS_SET) == "false" && !coreUser().sendRcptsContacts) { "OK stored the choice or turned contact receipts on" } + restart() + awaitReceiptsView() + click(Ui.DeliveryReceipts.Skip) + awaitReceiptsDisabledAlert() + click(DISMISS) + poll(what = "the chat list and the choice stored after `Don't show again`") { + !AlertManager.shared.hasAlertsShown() && exists(Ui.ChatList.Search) && storedSetting(RECEIPTS_SET) == "true" + } + check(!coreUser().sendRcptsContacts) { "`Don't show again` turned contact receipts on" } + val after = restart() + poll(what = "the chat list after the restart") { after.chatList.shown(NOTES) } + check(!exists(Ui.DeliveryReceipts.Enable) && !seesText(RECEIPTS_TITLE)) { "`$RECEIPTS_TITLE` shown again after `Don't show again`" } + } + + private fun CliPeer.sendsImage(fileId: Int) { + this sends "/img @alice $FIXTURE_JPG" + this expectsMessage "/f @alice $FIXTURE_JPG" + this expects "use /fc $fileId to cancel sending" + eventually("completed uploading file $fileId (test.jpg) for alice", CONNECT_TIMEOUT_MS) + } + + private fun fileOf(id: Long): CIFile? = chatModel.chatsContext.chatItems.value.firstOrNull { it.id == id }?.file + + private fun user(): User = chatModel.currentUser.value!! + + private fun clearReceiptsChoiceViaApi() = appPrefs.privacyDeliveryReceiptsSet.set(false) + + private fun coreUser(): User = runBlocking { chatController.listUsers(null) }.first { it.user.activeUser }.user + + private fun turnOffContactReceiptsViaApi() { + val u = user() + runBlocking { chatController.apiSetUserContactReceipts(u, UserMsgReceiptSettings(enable = false, clearOverrides = false)) } + chatModel.currentUser.value = u.copy(sendRcptsContacts = false) + } + + private fun membershipStatus(group: String): GroupMemberStatus? = + (chatModel.chats.value.firstOrNull { it.chatInfo.chatViewName == group }?.chatInfo as? ChatInfo.Group)?.groupInfo?.membership?.memberStatus + + private fun directChat(name: String) = + chatModel.chats.value.firstOrNull { (it.chatInfo as? ChatInfo.Direct)?.contact?.displayName == name } + + private fun storedAsPlainJpeg(file: CIFile): Boolean { + val bytes = File(getAppFilePath(file.fileSource!!.filePath)).readBytes() + return bytes.size.toLong() == file.fileSize && bytes.take(JPEG_MAGIC.size) == JPEG_MAGIC + } + + private fun UiScope.awaitReceiptsView() = + poll(what = "the `$RECEIPTS_TITLE` screen with Enable and `Don't enable`") { + seesText(RECEIPTS_TITLE) && exists(Ui.DeliveryReceipts.Enable) && exists(Ui.DeliveryReceipts.Skip) && !exists(Ui.ChatList.Search) + } + + private fun UiScope.awaitReceiptsDisabledAlert() = + poll(what = "the `Delivery receipts are disabled!` alert with OK and `Don't show again`") { + seesText("Delivery receipts are disabled!") && seesText("You can enable them later via app Your privacy settings.") && + "OK" in textsOf(CONFIRM) && "Don't show again" in textsOf(DISMISS) + } + + private fun UiScope.awaitReceipt(peer: CliPeer, chatRef: String, text: String) = + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "${peer.name} to get alice's receipt for `$text`") { peer.itemStatus(chatRef, text) == "sndRcvd" } + + private fun UiScope.awaitReceived(app: App, chat: String, text: String) = + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice to receive `$text` in $chat") { app.chatList.chat(chat).chatItems.any { it.content.text == text } } + private fun UiScope.toggleShowLastMessages(app: App) { app.settings() click(Ui.Settings.Privacy) diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/SettingsRootTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/SettingsRootTest.kt new file mode 100644 index 0000000000..927ae9ef34 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/SettingsRootTest.kt @@ -0,0 +1,106 @@ +package chat.simplex.app.ui.settings + +import chat.simplex.app.ui.harness.ChatDataScreen +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.WhatsNewScreen +import chat.simplex.app.ui.harness.chatRunningInCore +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.ui.theme.DefaultTheme +import chat.simplex.common.ui.theme.DefaultThemeMode +import org.junit.Test + +private const val CURRENT_THEME = "CurrentTheme" +private const val CHAT_STOPPED = "Chat is stopped" +private const val ABOUT_TEXT = "Private and secure messaging." + +private class SettingsRow(val name: String, val key: UiKey, val title: String, val content: String) { + fun shown(s: UiScope): Boolean = s.seesText(title) && s.seesText(content) +} + +private val APPEARANCE = SettingsRow("Appearance", Ui.Settings.Appearance, "Appearance", "Color mode") +private val PRIVACY = SettingsRow("Your privacy", Ui.Settings.Privacy, "Your privacy", "More privacy") +private val HELP = SettingsRow("Help & support", Ui.Settings.Help, "Help & support", "How to use it") +private val CHAT_DATA = SettingsRow("Chat data", Ui.Settings.Database, "Chat data", "Database passphrase & export") +private val NETWORK = SettingsRow("Network & servers", Ui.Settings.Network, "Network & servers", "Advanced network settings") +private val CALLS = SettingsRow("Audio & video calls", Ui.Settings.Calls, "Your calls", "Always use relay") +private val VERSION = SettingsRow("the version row", Ui.Settings.Version, "App version", "Developer tools") +private val ROWS = listOf(APPEARANCE, PRIVACY, HELP, CHAT_DATA, NETWORK, CALLS, VERSION) + +class SettingsRootTest : UiTest() { + @Test fun settingsRoot_rowsOpenTheirScreens() = uiTest { + val alice = app("alice") + alice.settings() + poll(what = "Your settings with all its rows") { seesText("Your settings") && ROWS.all { exists(it.key) } } + check(ROWS.none { it.shown(this) }) { "a settings screen is shown before any row was clicked: ${ROWS.filter { it.shown(this) }.map { it.name }}" } + for (row in ROWS) { + click(row.key) + poll(what = "${row.name} open in place of Your settings, and no other settings screen") { + row.shown(this) && !exists(Ui.Settings.Appearance) && ROWS.none { it !== row && seesText(it.content) } + } + escape() + poll(what = "Your settings again after closing ${row.name}") { exists(Ui.Settings.Appearance) && !seesText(row.content) } + } + escape() + poll(what = "Your settings to close") { !exists(Ui.Settings.Appearance) && exists(Ui.ChatList.Search) } + } + + @Test fun settingsRoot_itemsDisabledWhenChatStopped() = uiTest { + val alice = app("alice") + alice.settings() + check(CHAT_STOPPED !in descriptionsOf(Ui.Settings.Database)) { "Chat data is marked as stopped while the chat runs" } + click(Ui.Settings.Database) + waitFor(Ui.Database.Management) + ChatDataScreen(this).management() + click(Ui.Database.RunChat) + poll(what = "the Stop chat? alert with Stop") { seesText("Stop chat?") && "Stop" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the chat stopped on screen and in the core") { seesText(CHAT_STOPPED) && chatModel.chatRunning.value == false && !chatRunningInCore() } + escape() + poll(what = "Chat data again") { !exists(Ui.Database.RunChat) && exists(Ui.Database.Management) } + escape() + poll(what = "Your settings with Chat data marked as stopped") { exists(Ui.Settings.Privacy) && CHAT_STOPPED in descriptionsOf(Ui.Settings.Database) } + for (row in listOf(PRIVACY, NETWORK, CALLS)) { + click(row.key) + settle() + check(exists(Ui.Settings.Appearance) && !row.shown(this)) { "${row.name} opened while the chat is stopped" } + } + click(Ui.Settings.Help) + poll(what = "Help & support opening while the chat is stopped") { exists(Ui.Help.HowToUse) && !exists(Ui.Settings.Appearance) } + click(Ui.Help.HowToUse) + settle() + check(exists(Ui.Help.HowToUse) && !seesText("Welcome alice!")) { "How to use it opened while the chat is stopped" } + click(Ui.Help.WhatsNew) + settle() + check(exists(Ui.Help.WhatsNew) && !WhatsNewScreen(this).shown()) { "What's new opened while the chat is stopped" } + click(Ui.Help.About) + poll(what = "About SimpleX Chat opening while the chat is stopped") { seesText(ABOUT_TEXT) && !exists(Ui.Help.HowToUse) } + } + + @Test fun userPicker_colorModeSwitcher() = uiTest { + val alice = app("alice") + val systemMode = CurrentColors.value.base.mode + val flipped = if (systemMode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT + check(CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME && storedSetting(CURRENT_THEME).let { it == null || it == DefaultTheme.SYSTEM_THEME_NAME }) { + "the color mode is not System before the switch: ${CurrentColors.value.name}, stored ${storedSetting(CURRENT_THEME)}" + } + alice.userPicker() + click(Ui.UserPicker.ColorMode) + poll(what = "the opposite color mode applied and stored") { CurrentColors.value.base.mode == flipped && storedSetting(CURRENT_THEME) == themeName(flipped) } + click(Ui.UserPicker.ColorMode) + poll(what = "the first color mode applied again, stored as an explicit theme") { + CurrentColors.value.base.mode == systemMode && storedSetting(CURRENT_THEME) == themeName(systemMode) + } + rightClick(Ui.UserPicker.ColorMode) + poll(what = "the System color mode applied and stored") { + CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME && storedSetting(CURRENT_THEME) == DefaultTheme.SYSTEM_THEME_NAME + } + } + + private fun themeName(mode: DefaultThemeMode): String = + if (mode == DefaultThemeMode.LIGHT) DefaultTheme.LIGHT.themeName else appPrefs.systemDarkTheme.get()!! +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/VersionDevToolsTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/VersionDevToolsTest.kt new file mode 100644 index 0000000000..9010edaef1 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/VersionDevToolsTest.kt @@ -0,0 +1,90 @@ +package chat.simplex.app.ui.settings + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.chatConsole +import chat.simplex.app.ui.harness.closeDeveloperTools +import chat.simplex.app.ui.harness.consoleShows +import chat.simplex.app.ui.harness.developerTools +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +private const val DEVELOPER_TOOLS = "DeveloperTools" +private const val SHOW_FOOTER = "Show: Database IDs and Transport isolation option." +private const val HIDE_FOOTER = "Hide: Database IDs and Transport isolation option." +private const val CONTACTS_CMD = "/contacts" +private const val SQL_CMD = "/sql chat select 1" +private const val SQL_BLOCKED = "error chat commandError Failed reading: empty" +private const val ONE_HAND_CARD_SHOWN = "OneHandUICardShown" +private const val TALK_TO_SOMEONE = "Talk to someone" + +class VersionDevToolsTest : UiTest() { + @Test fun version_screenShowsVersions() = uiTest { + val alice = app("alice") + val (name, code) = + if (appPlatform.isDesktop) BuildConfigCommon.DESKTOP_VERSION_NAME to BuildConfigCommon.DESKTOP_VERSION_CODE + else BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE + val core = runBlocking { chatController.apiGetVersion() } ?: error("the core did not return its version") + alice.settings() + assertEquals("$name ($code)", textOf(Ui.Settings.Version)) + check(!seesText("App build: $code")) { "the build is shown before the version screen opened" } + click(Ui.Settings.Version) + poll(what = "the App version screen with the app build and the core and simplexmq versions") { + seesText("App version") && seesText("App version: v$name") && seesText("App build: $code") && + seesText("Core version: v${core.version}") && seesText("simplexmq: v${core.simplexmqVersion} (${core.simplexmqCommit.take(7).padStart(2)})") + } + } + + @Test fun dev_showDeveloperOptionsSection() = uiTest { + val alice = app("alice") + developerTools(alice) + check(storedSetting(DEVELOPER_TOOLS) != "true" && seesText(HIDE_FOOTER) && !seesText(SHOW_FOOTER) && !seesText("Enable logs")) { + "developer options before the switch: stored ${storedSetting(DEVELOPER_TOOLS)}, texts ${texts().take(40)}" + } + click(Ui.DevTools.ShowOptions) + poll(what = "the Developer options section with the Show footer, stored as on") { + storedSetting(DEVELOPER_TOOLS) == "true" && seesText(SHOW_FOOTER) && !seesText(HIDE_FOOTER) && seesText("Enable logs") && seesText("Show internal errors") + } + click(Ui.DevTools.ShowOptions) + poll(what = "the Developer options section hidden with the Hide footer, stored as off") { + storedSetting(DEVELOPER_TOOLS) == "false" && seesText(HIDE_FOOTER) && !seesText(SHOW_FOOTER) && !seesText("Enable logs") && !seesText("Show internal errors") + } + } + + @Test fun console_sendCommand() = uiTest { + val alice = app("alice") + chatConsole(alice) + check(!consoleShows("> $CONTACTS_CMD") && !consoleShows("> $SQL_CMD")) { "the commands are in the console before they were sent" } + typeInto(Ui.Chat.Message, CONTACTS_CMD) + click(Ui.Chat.Send) + poll(what = "the command and the core's contactsList response in the console, and the field cleared") { + consoleShows("> $CONTACTS_CMD") && consoleShows("< * contactsList") && editableText(Ui.Chat.Message).isNullOrEmpty() + } + typeInto(Ui.Chat.Message, SQL_CMD) + click(Ui.Chat.Send) + poll(what = "the /sql command and the error response in the console") { consoleShows("> $SQL_CMD") && consoleShows("< error chat") } + val items = chatModel.terminalItems.value + val cmd = items.indexOfLast { it.label == "> $SQL_CMD" } + assertEquals("the response to $SQL_CMD without SimpleX Lock and developer tools", SQL_BLOCKED, items.getOrNull(cmd + 1)?.details) + } + + @Test fun dev_resetAllHints() = uiTest { + val alice = app("alice") + waitFor(Ui.ChatList.Search) + check(appPrefs.oneHandUICardShown.get() && !appPrefs.hintPreferences.all { it.isUnchanged() } && !exists(Ui.ChatList.OneHandCard) && !seesText(TALK_TO_SOMEONE)) { + "the hints are already reset before Reset all hints: card shown ${exists(Ui.ChatList.OneHandCard)}, onboarding shown ${seesText(TALK_TO_SOMEONE)}" + } + developerTools(alice) + click(Ui.DevTools.ResetHints) + poll(what = "every hint preference back to its default, the one-hand card hint stored as not shown") { + appPrefs.hintPreferences.all { it.isUnchanged() } && storedSetting(ONE_HAND_CARD_SHOWN) == "false" + } + closeDeveloperTools("the connect onboarding in place of the chat list") { seesText(TALK_TO_SOMEONE) && !exists(Ui.ChatList.Search) } + } +} diff --git a/apps/multiplatform/scripts/uitest-android.sh b/apps/multiplatform/scripts/uitest-android.sh index d022af3bca..c74c00e725 100755 --- a/apps/multiplatform/scripts/uitest-android.sh +++ b/apps/multiplatform/scripts/uitest-android.sh @@ -1,6 +1,6 @@ #!/bin/sh # Usage: scripts/uitest-android.sh [am instrument -e args...] -# Needs scripts/uitest.sh to have run once (it builds the CLI and test servers and writes build/uitest/env.properties). +# Needs scripts/uitest.sh to have run once, or `scripts/uitest.sh --prepare-only` (it builds the CLI and test servers and writes build/uitest/env.properties). # Env: # UITEST_SERIAL adb serial of the device or emulator to use; when unset, a Waydroid instance is claimed with scripts/waydroid/wd.sh # WD_SH another wd.sh to use (default scripts/waydroid/wd.sh; see scripts/waydroid/README.md) @@ -25,10 +25,10 @@ bridge_log="$out/bridge.log" avail_mb=$(awk '/^MemAvailable:/ {print int($2 / 1024)}' /proc/meminfo) [ "$avail_mb" -ge 4500 ] || { echo "uitest-android: only $avail_mb MB available, need 4500 (bridge + Waydroid); free memory first" >&2; exit 1; } java="${JAVA_HOME:+$JAVA_HOME/bin/}java" -[ -s "$mp/build/uitest/env.properties" ] || { echo "uitest-android: run scripts/uitest.sh once first; it builds the CLI and test servers and writes build/uitest/env.properties" >&2; exit 1; } +[ -s "$mp/build/uitest/env.properties" ] || { echo "uitest-android: run scripts/uitest.sh (or scripts/uitest.sh --prepare-only) once first; it builds the CLI and test servers and writes build/uitest/env.properties" >&2; exit 1; } cp_file="$mp/build/uitest/bridge.classpath" h="$mp/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness" -bridge_srcs="$h/UiBridge.kt $h/CliPeer.kt $h/VtScreen.kt $h/TestServers.kt $h/UiEnv.kt $h/UiJvm.kt $mp/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ServersConfig.kt $mp/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PeerLines.kt" +bridge_srcs="$h/UiBridge.kt $h/CliPeer.kt $h/VtScreen.kt $h/TestServers.kt $h/Socks5Stub.kt $h/UiEnv.kt $h/UiJvm.kt $mp/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ServersConfig.kt $mp/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PeerLines.kt" wd_pid= cleanup() { [ -z "${bridge_pid:-}" ] || kill -TERM "$bridge_pid" 2>/dev/null || true @@ -80,7 +80,7 @@ else fi adb -s "$serial" install -r -t "$test_apk" step "APKs installed" -for p in 7777 $((base + 1)) $((base + 2)); do adb -s "$serial" reverse "tcp:$p" "tcp:$p" >/dev/null; done +for p in 7777 $((base + 1)) $((base + 2)) $((base + 11)) $((base + 12)) $((base + 20)) $((base + 90)); do adb -s "$serial" reverse "tcp:$p" "tcp:$p" >/dev/null; done prepare_app() { adb -s "$serial" shell pm clear "$pkg" >/dev/null adb -s "$serial" shell settings put global heads_up_notifications_enabled 0 diff --git a/apps/multiplatform/scripts/uitest.sh b/apps/multiplatform/scripts/uitest.sh index c0c268d790..6c9bda668c 100755 --- a/apps/multiplatform/scripts/uitest.sh +++ b/apps/multiplatform/scripts/uitest.sh @@ -1,5 +1,7 @@ #!/bin/sh # Usage: scripts/uitest.sh [gradle args] e.g. --tests '*ChatListMenuTest', -PuiForks=2, -PuiForkEvery=N +# scripts/uitest.sh --prepare-only only the CLI, test servers, peer template and build/uitest/env.properties (for uitest-android.sh) +# Env: UITEST_CLI and UITEST_SERVERS, absolute paths of a prebuilt CLI and uitest-servers (cabal -f uitest), skip the cabal build. # Runs the desktop UI tests (common/src/uiTestShared + desktopTest/.../ui) in a private network namespace with local servers. # Needs cabal/GHC for the CLI peers and test servers, and the desktop native libs (./gradlew :desktop:cmakeBuildAndCopy). # Results: common/build/reports/tests/desktopUiTest/pass1; run directories (failure screenshots) in build/uitest/run-*. @@ -17,6 +19,12 @@ if [ "${1:-}" = "--inside" ]; then SIMPLEX_UITEST_RUNNER=1 ./gradlew -g "$home" --offline --no-daemon "$kotlin_in_process" :common:desktopUiTest -PuiRunDir="$run" -PuiPass=1 "$@" exit $? fi +prepare_only= +if [ "${1:-}" = "--prepare-only" ]; then + shift + [ "$#" -eq 0 ] || { echo "uitest: --prepare-only takes no other arguments" >&2; exit 1; } + prepare_only=1 +fi avail_mb=$(awk '/^MemAvailable:/ {print int($2 / 1024)}' /proc/meminfo) [ "$avail_mb" -ge 3000 ] || { echo "uitest: only $avail_mb MB available, need 3000; free memory first" >&2; exit 1; } @@ -28,9 +36,17 @@ mkdir -p "$run/jvmtmp" "$run/cache" ls -1dt build/uitest/run-* 2>/dev/null | tail -n +7 | xargs -r rm -rf repo=$(cd ../.. && pwd) -(cd "$repo" && cabal build -O0 -f uitest --builddir=dist-uitest exe:simplex-chat exe:uitest-servers) -cli=$(cd "$repo" && cabal list-bin -O0 -f uitest --builddir=dist-uitest exe:simplex-chat) -servers=$(cd "$repo" && cabal list-bin -O0 -f uitest --builddir=dist-uitest exe:uitest-servers) +if [ -n "${UITEST_CLI:-}${UITEST_SERVERS:-}" ]; then + for exe in "${UITEST_CLI:-}" "${UITEST_SERVERS:-}"; do + case "$exe" in /*) [ -x "$exe" ] ;; *) false ;; esac || { echo "uitest: UITEST_CLI and UITEST_SERVERS must both be absolute paths of executables" >&2; exit 1; } + done + cli=$UITEST_CLI + servers=$UITEST_SERVERS +else + (cd "$repo" && cabal build -O0 -f uitest --builddir=dist-uitest exe:simplex-chat exe:uitest-servers | tail -n 1) + cli=$(cd "$repo" && cabal list-bin -O0 -f uitest --builddir=dist-uitest exe:simplex-chat | tail -n 1) + servers=$(cd "$repo" && cabal list-bin -O0 -f uitest --builddir=dist-uitest exe:uitest-servers | tail -n 1) +fi peer_template() { t="$run/peer-template.tmp" @@ -45,13 +61,26 @@ peer_template() { peer_template & template_pid=$! +lib=common/src/commonMain/cpp/desktop/libs/linux-x86_64/libapp-lib.so +cat > build/uitest/env.properties <&2; exit 1; } prepare_key=$({ find . -path ./build -prune -o -path ./.gradle -prune -o -path '*/build' -prune -o \( -name '*.gradle.kts' -o -name gradle.properties -o -name '*.versions.toml' \) -type f -print find common/src/commonMain/cpp -path '*/libs' -prune -o -type f -print } | LC_ALL=C sort | xargs sha256sum | sha256sum; ls -l --time-style=full-iso common/src/commonMain/cpp/desktop/libs/linux-x86_64 | grep -v libapp-lib.so) prepare_key=$(printf '%s' "$prepare_key" | sha256sum | cut -d' ' -f1) -lib=common/src/commonMain/cpp/desktop/libs/linux-x86_64/libapp-lib.so if [ "$prepare_key" = "$(cat build/uitest/prepare.key 2>/dev/null)" ] && [ -f "$lib" ]; then echo "uitest: build files and native libs unchanged, skipping the outside Gradle step" else @@ -60,14 +89,6 @@ else ./gradlew -g "$home" --stop >/dev/null 2>&1 || true echo "$prepare_key" > build/uitest/prepare.key fi - -cat > build/uitest/env.properties < `; + - `remotePort` → `ok `: the port a bridge CLI peer acting as a remote-desktop controller listens on (`TestServers.remoteCtrlPort`, port base + 90; the Android runner forwards it with `adb reverse`); - `start ` → `ok` once the peer has started; - `type ` → `ok`; - `drain ` → `lines N`, then N lines; - `stop ` → `ok`; + - `server start|stop|reset|address ` → `ok [
]`, `server baseline` → `ok` (0.5.6); + - `socks start` → `ok `, `socks connections` → `lines N` + N lines, `socks stop` → `ok` (0.5.6); - any failure → `err `. Matching runs device-side in `PeerLines`. Bridge peer directories (`/bridge-peers/-`) are not deleted. +- **Remote desktop** (`RemoteScreens.kt`, shared): a current-build CLI peer plays either side; no Haskell or core change is needed. + - CLI as the mobile host (desktop GUI controller, 04 §K): `setDeviceName`, `connectRemoteCtrl(invitation)` → `RemoteCtrlOffer(ctrlName, sessionCode)`, `verifyRemoteCtrl(code)` → controller name, `stopRemoteCtrl()`. The invitation is read with "Share link" (developer tools on) from the in-memory clipboard. Inside the runner's namespace the only interface is `lo`, so the app's TLS server (random port) offers 127.0.0.1 and the CLI connects to it; no multicast is involved for a new pairing. + - CLI as the controller (Android GUI host, 05 §12): `startRemoteHost(port)` sends `/start remote host new addr=127.0.0.1 iface="lo" port=` and returns the invitation; `awaitRemoteHostCode()`, `awaitNewRemoteHost()` → host name. The controller runs in the bridge on the host, listens on `Bridge.remoteCtrlPort()` (port base + 90, all interfaces), and the device reaches 127.0.0.1: through `adb reverse`, so no Waydroid bridge address or multicast is needed. + - GUI: `linkMobile(app)` → `LinkMobileScreen` (`deviceName()`, `addMobile()` → invitation), `linkMobileHost(app, mobile, name)` → remote host id, `awaitMobileSession(name)`; `useFromDesktop(app)` → `ConnectDesktopScreen` (`deviceName()`, `paste`, `connect`, `confirm(code)`, `awaitConnected(desktopName, code)`), `linkToDesktop(app, controller, desktopName, port)`. Cleanup through the API: `endMobileSessionViaApi(mobile)`, `endDesktopSessionViaApi(controller)`; a test must not end with a session open, because stopping the peer afterwards makes the app switch profiles during the next reset. ### 0.5.6 Servers -- `tests/UITestServers.hs` (cabal flag `uitest`, `exe:uitest-servers`) takes ` `, runs **`smp1` only** (persistent store, port base + 1), prints `ready smp1 smp://…@localhost:` and exits on stdin EOF. It has no control protocol. -- `TestServers`: 120 slots (`slot-.lock` in the run directory), base 20000 + 100 k. It is spawned under `setpriv --pdeathsig KILL` from the test thread during the first reset (in the bridge, from its main thread). -- `configureServers(smp, xftp)` disables the operator groups and adds a custom group with `smp1` and `xftp1` (base + 2). Since 2026-10-19 `uitest-servers` also runs the XFTP server (`xftpServerConfig` from `tests/ChatClient.hs`, files in `/xftp1`) and prints `ready xftp1 …`; CLI peers get `--xftp-server`. `CliPeer.createFile(name, content)` writes a file on the peer's host (Android: through the bridge `file` command). Any XFTP test fails until P1b. +- `tests/UITestServers.hs` (cabal flag `uitest`, `exe:uitest-servers`) takes ` `, starts `smp1` (`localhost`, base + 1) and `xftp1` (`localhost`, base + 2) at launch, prints `ready
` for each, and exits on stdin EOF. `smp2` (`127.0.0.1`, base + 11) and `xftp2` (`127.0.0.1`, base + 12) start only on request; the different host is needed because `/_servers` rejects two servers with the same host. Each server keeps its store in `/` (SMP: `persistentServerStoreCfg`, so queues and undelivered messages survive a restart; XFTP: store log and files). Control protocol on stdin, one line per command, answered on stdout (server log lines are ignored): + - `start ` → `ready
` once it listens (also when it already runs); + - `stop ` → `stopped ` after the server has saved its store and closed its port (`cancel` of its thread, which runs the server's own `finally` save); + - `reset ` → `ready
`: stop, delete `/`, start ("restarted without its store", for AUTH/expired-file rows); + - any failure → `error `. +- `TestServers` (desktop harness): 120 slots (`slot-.lock` in the run directory), base 20000 + 100 k. It is spawned under `setpriv --pdeathsig KILL` from the test thread during the first reset (in the bridge, from its main thread). **Per JVM**: each test JVM (Gradle fork) has its own slot, its own `uitest-servers` and its own port block, and the tests of one JVM run one at a time, so a test that stops a server affects only itself; with `-PuiForks=N` there are N independent server sets. Android runs its tests one at a time against the one bridge. API (same names on both platforms; Android's `TestServers` object in `android/.../harness/CliPeer.kt` sends `server ` to the bridge): `start(name): String` (the address), `stop(name)`, `reset(name): String`, `address(name): String` (fails if the server is not running), `restoreBaseline()`. `restoreBaseline()` runs in every reset (desktop `resetForTest`, Android `resetForTest` through the bridge, and the bridge when its client disconnects): it stops `smp2`/`xftp2` and starts `smp1`/`xftp1` if a previous test left them stopped; with nothing changed it sends no command. `smp1`/`xftp1` keep their stores across tests (each test's databases are new, so old queues are unused). +- `configureServers(smp, xftp)` disables the operator groups and adds a custom group with `smp1` and `xftp1`. CLI peers get `-s` and `--xftp-server` from `PeerFlags(smpServer = "smp1", xftpServer = "xftp1")` (server names; Android passes `smp=`/`xftp=` in the bridge `start` flags), so a peer can live on `smp2`. `CliPeer.createFile(name, content)` writes a file on the peer's host (Android: through the bridge `file` command). +- **SOCKS stub** (`Socks5Stub.kt`, desktop harness): an RFC 1928 CONNECT-only SOCKS5 server with no-auth and RFC 1929 username/password (the app's default "isolate" mode sends a random username), IPv4/IPv6/domain targets, bound to `127.0.0.1:`; it records each CONNECT as `SocksConnection(host, port, username)` (uiTestShared `ServersConfig.kt`) and relays the bytes. Tests use `SocksProxy.start()`, `SocksProxy.port`, `SocksProxy.connections()`; the harness stops it at the end of every test. On Android the stub runs in the bridge (`socks start` → `ok `, `socks connections` → `lines N` + ` `, `socks stop`), and the device reaches it through `adb reverse`. +- **Ports in a JVM's block**: base + 1 `smp1`, + 2 `xftp1`, + 11 `smp2`, + 12 `xftp2`, + 20 SOCKS stub. `uitest-android.sh` reverses all five. +- Outages in tests stay short: the agents back off (message retries stay at about 2 s for the first 10 s, then grow ×1.5; a failed connection is cached for 3 s, `persistErrorInterval`), so a server is started again within a few seconds and waits after the start use `CONNECT_TIMEOUT_MS`. ### 0.5.7 Helpers and timeouts @@ -211,7 +225,7 @@ The rules for test authors are in `apps/multiplatform/common/src/uiTestShared/RE - claims the Waydroid instance and starts it in the background if needed; - starts the bridge (`setsid java -cp … UiBridge 7777`) and kills it by `$!` on exit; - installs the app APK unless its sha256 and `lastUpdateTime` match the stamp, and always the test APK; - - `adb reverse` for 7777, base + 1 and base + 2. + - `adb reverse` for 7777, base + 1, base + 2 and base + 90 (the remote-desktop controller port, 0.5.5). - Each pass: `pm clear`, grants POST_NOTIFICATIONS, CAMERA and RECORD_AUDIO, then `am instrument -w -r`. After "Process crashed" it resumes with `-e notClass ` (at most 4 passes). - Output in `build/uitest-android/run-/`: - `results.txt`, one line per test, `# `: 0 pass, −2 fail, −3/−4 skipped, or `crashed`; @@ -244,11 +258,11 @@ UITEST_SERIAL= scripts/uitest-android.sh # anothe | 7.2, 7.3 | fixtures (`app(…, fixture)`, `restart(fixture)`); `RecordingNtfManager`, `UiTestPlatform` and the seams of 11.2; deleting `dataDir/tmp`; the `CC.ShowVersion` check; the lock-screen restart path and the alert exit of the restart poll; the `changingActiveUserMutex` check, manual `migrationState` handling, and the `OnboardingSharedState`/`BadgeStore` roots | | 7.4, 7.5 | `UiDriver`/`UiNode`/`InProcessDriver`; `MR.strings` lookups and exactly-one matching; `hold`, `dragTo`, `hover`, `clickAtTextRange`, `press(key, modifiers)`, `wait(d)`, `composeField()` (placeholder); the report directory with semantics dump, peer tails and command log (only the failure PNG and the message exist) | | 7.6, 7.7, 7.8 | `UiFiles` dialog responders (pending dialogs are only completed empty at reset); `LocalClipboard`; a throwing URI handler; toast and notification recording; screenshots; coverage | -| 8.1 | the Stable CLI and `CliVersion`; `--mute` (the D-Bus notification concern of 8.3 stands); `--xftp-server`; `$HOME/Downloads`; the start–stop–start cycle; the `! `/`> `/`\ ` typing rule; `PeerFlags.key`, `PeerServers`; `expectsAny`, `repeatUntil`; `stop`/`start`/`snapshot`/`restore` inside a test; `RelayPeer`; mobile hosts | -| 8.2 | `smp2`, `xftp2`, the names resolver, the control protocol, `restoreBaseline` (`xftp1` is served since 2026-10-19) | +| 8.1 | the Stable CLI and `CliVersion`; `--mute` (the D-Bus notification concern of 8.3 stands); `--xftp-server`; `$HOME/Downloads`; the start–stop–start cycle; the `! `/`> `/`\ ` typing rule; `PeerFlags.key` (`PeerServers` is built as `PeerFlags.smpServer`/`xftpServer`, 0.5.6); `expectsAny`, `repeatUntil`; `stop`/`start`/`snapshot`/`restore` inside a test; `RelayPeer`; mobile hosts | +| 8.2 | the names resolver, `register`/`clear-names` (`smp2`, `xftp2`, the control protocol and `restoreBaseline` are built, 0.5.6) | | 9.1–9.3 | `GuiUser`, `ChatRow` and the typed row classes; `uiTest(realTimes)`, `app(name, onboarded, fixture)`, `connect(gui, peer)`, `relay`, `mobileHost`, `servers`, `allowUncaught`, `screenshot`, `shouldHave`; the one-`app()`-per-test rule; `…CompatStableTest` subclasses | | 10.1 | `CatalogueTraceabilityTest`, stub generation, the catalogue in the repo; a traceability rule for the desktop-only (`chat.simplex.app.ui.desktop`) and Android-only (`chat.simplex.app.ui`, `chat.simplex.app.ui.back`) packages | -| 11 | local HTTP and SOCKS stubs, committed media fixtures, multicast tests, server control, SQL fixtures | +| 11 | the local HTTP stub, committed media fixtures, multicast tests, SQL fixtures (the SOCKS stub and server control are built, 0.5.6) | | Android | notification tests; the 10-run exit check; `wd.sh net isolate` in the runner; `testTagsAsResourceId` and `ui-test-manifest` (not needed by the instrumented layer); the debug network-security-config (approved, 13) | ## Terms @@ -1550,6 +1564,8 @@ Each entry gives the symptom, the cause (how it was confirmed), and the fix or m 51. **The notification shade took window focus on Waydroid.** A "bob Connected" heads-up left the expanded shade focused (`mCurrentFocus=NotificationShade`), and every later Espresso action failed with `RootViewWithoutFocusException`. The runner disables heads-up notifications and each Android reset collapses the shade (environment setup, not app behaviour under test). 52. **Native allocator deadlock under arm64 translation stalls the whole process.** Several threads in `scudo::HybridMutex::lockSlow` (`wd.sh bt`) block every thread, the in-process watchdog and the signal catcher included, so a run hung for over an hour twice. `uitest-android.sh` now force-stops the app when the instrumentation output has not changed for `UITEST_STALL_S` (420 s); the pass ends abnormally, the stuck test is reported as `crashed`, and the run resumes. A pass is resumed after any abnormal end (`INSTRUMENTATION_RESULT: shortMsg=…`: "Process crashed", or an ANR such as `keyDispatchingTimedOut` when the main thread is the one stuck). Environmental (Waydroid libndk), not an app result. 53. **Typing into the Android message field right after a chat opened was lost**: the composer resets the field while the chat opens. Without the old retry loop, the helper now waits for the field to be stable and lets the screen settle before the single `replaceText`. +54. **Database re-encryption, export and import are slow under arm64 translation** (Android on Waydroid, 2026-10-20). "Database encrypted!" arrived after 26 s and 44 s, and once after more than 30 s with no tracked API call in flight, so a 30 s wait failed although the encryption had succeeded. Waits for these database operations use `DB_WORK_TIMEOUT_MS` (120 s, `DatabaseScreens.kt`); every other wait keeps its normal timeout. +55. **Disabled rows inside a clickable container cannot be found** (2026-10-22). The user picker container is clickable (to close the picker, `UserPicker.desktop.kt:103`, `UserPicker.android.kt:142`), and rows disabled while the chat is stopped have no click handler, so their semantics merge into the container and `exists`/`waitFor`, which search the merged tree, never see their keys. `UserPickerTest.userPickerDisabledWhenChatStopped` is `@Ignore`d. Fix, not done: look up keyed nodes in the unmerged tree in both `UiScope`s (as `click` already does), checking that no absence assertion changes meaning. ### 15.4.1 Open flake: a CLI member's group join not completed (desktop, 2 of 5 runs) @@ -1618,6 +1634,45 @@ Fixed in the same review: `createGroup_opensChatAndInviteSheet` now checks the o 6. **Android: Back on the invite-members sheet right after creating a group sometimes closes the group chat too** (`BackKeyTest.back_closesContactAndGroupInfo`, 2 of 3 runs on 2026-10-19; the screen returns to the chat list instead of team's chat). The sheet registers two Back handlers (`ModalView` and `AddGroupMembersView.kt:44`), and the chat under it registers its own on Android (`ChatView.kt` `if (appPlatform.isAndroid && chatsCtx.secondaryContextFilter == null) BackHandler`); Android gives Back to the most recently registered enabled handler, so a re-registration of the chat's handler after the sheet opened would close the chat. Mechanism plausible, not confirmed. The test is marked `@KnownAppIssue("15.5 item 6", intermittent = true)`. +7. **Importing a file that is not a chat archive deletes all data** (`DatabaseView.kt:730-757`, `Archive.hs:122-136`; found 2026-10-20 by `ChatDataTest.dbMgmt_importInvalidArchive`). Steps: Settings → Chat data → Database passphrase & export → Import database → choose a file that is not a zip → Import. + - `importArchive` calls `apiDeleteStorage()` before the archive is checked; the core closes both stores and deletes the chat and agent databases, their `.bak` backups, files, assets and temp. + - `apiImportArchive` then fails ("Parsing of archive structure failed: Cannot locate end of central directory") and throws a `java.lang.Exception`; both handlers catch only `Error` (`DatabaseView.kt:752,757`), so "Error importing chat database" is never shown. + - `stopChatRunBlockStartChat` then starts chat on the closed store: the user sees "Error starting chat" with "failed to list users … (withTransaction: database closed)", the old chat list stays on screen from memory, and chat cannot start. + - After an app restart new empty databases are created with the stored key and there is no user, so desktop shows the welcome screen (`Core.kt:153-168`; derived from code, the test does not restart). Every profile, contact and message is lost, backups included. + - The test requires the error alert and that alice and her note are still there; it is marked `@KnownAppIssue("15.5 item 7")`. Fix idea, not done: validate (or extract to a temporary location) before deleting storage, and catch `Exception` in the import handlers. + +8. **Unsaved group preference and member admission edits are discarded when any group event arrives** (`GroupPreferences.kt:44-45`, `MemberAdmission.kt:35-36`). The edit state is `rememberSaveable(gInfo, …)`, keyed on the whole `GroupInfo`; every new message replaces it with a new object (new timestamps, `ChatModel.kt` `addChatItem` → `updateChatInfo`), which re-initialises both the edited and the saved copy. The edit silently reverts and leaving the screen no longer asks "Save preferences?". Test: `GroupPreferencesTest.groupPrefs_unsavedEditsKeptWhenMessageArrives`, marked `@KnownAppIssue("15.5 item 8")`. Fix idea, not done: key the state on `groupInfo.groupId` and the saved preferences, not the whole object. + +9. **`DropdownSelection.Custom.hashCode()` recurses forever** (`CustomTimePicker.kt:157-163`). `(this as? Custom).hashCode()` resolves to `Any?.hashCode()`, which calls the overridden `hashCode()` again (the comment says the cast prevents this; it does only for the data class, which generates its own `hashCode`). `toString()` of the object uses `hashCode()`, so logging or hashing `Custom` throws `StackOverflowError`. Found when a test key stringified the option; no user-visible path is known, and the keys now map options to seconds (`optionKey`). + +10. **Suspected, not reproduced: an empty message can be sent to a chat opened while Enter is pressed** (`ComposeView.kt` ~1396 and 1020-1023). All chats share one compose state, swapped one frame after `chatModel.chatId` changes; in that frame the send button is enabled from the previous chat's text, and `sendMessage` reads `composeState.value` later inside its coroutine, after the swap. Seen once as an empty `#team alice>` line in `forward_destinationProhibitsContent_alert` before `ChatListScreen.open()` waited for the chat to open. + +11. **`chatRunning` is set before the core is started** (`SimpleXAPI.kt` `startChat`: `chatModel.chatRunning.value = true` at :709, `apiStartChat()` at :723 after `startReceiver()` and `setLocalDeviceName`). Opening a chat in that window fails with `chatNotStarted` and shows "Failed to load chat — Please update the app and contact developers." Seen in `dbMgmt_importArchive_restoresData` after "Start chat?" (2026-10-20); the window is milliseconds for a person, so the impact is small. The test now waits for the core to report the chat running. + +12. **"Inappropriate content" reports are sent with a reason the core does not know** (`ChatModel.kt:5452-5461` serialises `ReportReason.Illegal` as `"illegal"`; the core's reasons are `spam`, `content`, `community`, `profile`, `other`, `Protocol.hs:287-302`; iOS `ChatTypes.swift:5958-5969` does the same). The core stores it as `RRUnknown "illegal"`, so CLI moderators, bots and the directory service see `report illegal`; app-to-app it round-trips because both apps map `"illegal"` back. A `content` report from the CLI shows in the apps as the raw word "content". Test: `MemberReportTest.report_member_spam_moderatorsReceive` (the content report is sent last), marked `@KnownAppIssue("15.5 item 12")`. Fix idea, not done: serialise as `"content"` and accept both when decoding. + +13. **Cancelling a live message and starting another within 3 s sends two live messages** (`SendMsgView.kt:546-552`). Each start launches an update loop `delay(3000); while (liveMessage != null) { update(); delay(3000) }`; cancel sets `liveMessage = null`, but a loop sleeping in `delay` does not see it, and when a new live message starts before it wakes, the old loop keeps running beside the new one. Both call `updateLiveMessage` while `sent == false`, so each starts its own live message: the contact gets two "[LIVE started] … hi!" messages (seen as `[on/off/8]` and `[on/off/9]` on 2026-10-20), and one of them stays live forever. Test: `LiveMessageTest.liveMessage_boltButton_emptyStart`, marked `@KnownAppIssue("15.5 item 13", intermittent = true)`. Fix idea, not done: cancel the previous loop's job when the live message ends, or run one loop per `LiveMessage` instance. + +14. **After leaving a group the app can show the membership as connected again** (desktop, seen once on 2026-10-20 in `LeaveDeleteClearTest.leaveGroup_thenDeleteLocalCopy`). After "Leave group" the core replied (`LeftMemberUser`, applied by `leaveGroup` in `SimpleXAPI.kt:3722-3729`), bob printed "alice left the group" and the row showed "you left", yet the reopened group info listed "alice, you: connected", offered "Leave group" instead of "Delete group", and the compose field was an ordinary "Message" field. `GroupChatInfoView` reads the group from `chatModel.chats`, so the model's `membership` had reverted. Likely the same mechanism as item 1: a group event carrying a `GroupInfo` read before the leave, applied after the reply. Not confirmed. Marked `@KnownAppIssue("15.5 item 14", intermittent = true)`. + +15. **Core: removing several members at once breaks the removed members' copies of the group** (found 2026-10-20 by `MemberListTest.memberSelection_blockUnblockAndRemoveMultiple`; deterministic). `APIRemoveMembers` (`Commands.hs:3132`, `deleteMemsSend` :3206) sends one batch with an `XGrpMemDel` per removed member to every member, removed ones included. On a removed member, the event for their own removal runs `deleteGroupConnections` (`Subscriber.hs:3734-3740`, :4176 → `Internal.hs:2176` → `Store/Shared.hs:992`), which deletes the connection the batch arrived on; the next event's `saveGroupRcvMsg` (`Internal.hs:2928` → `Store/Messages.hs:276`) inserts into `msg_deliveries` with that connection id and fails `FOREIGN KEY constraint failed` (`chat_schema.sql:708`). The member sees "chat db error … FOREIGN KEY constraint failed", the rest of the batch is rolled back, and the other removed members stay "connected" in their copy of the group. With N members removed, N−1 hit it. No core test removes several members in one command. Fix idea, not done: stop applying a batch after one's own removal, or delete connection records after the whole batch. Marked `@KnownAppIssue("15.5 item 15")`. + +16. **An open contact info keeps the contact's old full name and bio after a profile update** (`ChatInfoView.kt:72`: `chat` is kept in `remember(contact.id)`, and `ChatInfoHeader` → `ChatInfoDescription` read that stale `chat.chatInfo`; `updateChatInfo` replaces the `Chat` with a copy, so the remembered one never changes). The display name updates, the full name, bio and image do not until the info is reopened. Test: `ContactInfoTest.contactInfoHeaderFollowsProfileUpdate`, marked `@KnownAppIssue("15.5 item 16")`. Fix idea: read `chatInfo` from the live model. + +17. **Desktop: the reaction chip's right-click menu shows a stale member list** (`ChatItemView.kt:204` `.onRightClick { showReactionsMenu() }` → `contextMenuOpenDetector`, `Modifier.desktop.kt:81`, keyed on `Unit`, so the closure keeps `r.userReacted` and the count from the first composition). After alice adds 👍 to bob's 👍, right-click lists only "bob", not "bob" and "you". Android uses long press through `combinedClickable` and is not affected. Test: `ReactionTest.reaction_chipClickOthers_adds`, marked `@KnownAppIssue("15.5 item 17", intermittent = true)` (passes on Android). The detector's keying is from memory of the Compose source; the run's stale list matches it. + +18. **A pending connection you joined is labelled "connection established" with an empty description** (`Commands.hs:3897-3899` stores `ConnSndReady` after the fast handshake's `sqSecured`; `ConnStatus.initiated` is null for `SndReady`, `ChatModel.kt:3341`, so `displayName` takes the branch commented "this should not be in the chat list", `ChatModel.kt:3276-3278`; iOS `ChatTypes.swift:2784-2786` is the same). Expected "accepted invitation" / "via one-time link". Test: `PendingConnectionTest.pendingInfoAcceptedShowsNoLink`, marked `@KnownAppIssue("15.5 item 18")`. + +19. **Suspected, not reproduced: a member–contact merge does not reach an open group info** (found by reading code on 2026-10-20). The Kotlin app has no handler for the core event `contactAndMemberAssociated` (`Subscriber.hs:3193,3200`), so while group info stays open after the core associates a member with an existing contact, the member keeps `memberContactId = null` and its "Message" button takes the "create member contact" path. `GroupLinksTest.groupLink_existingContactMergedWithMember` reopens group info and does not exercise this. + +20. **Core: pasting the full link of a group you are joining offers a second join request** (deterministic; `JoiningTest.connectPlan_connectingProhibit`). After "Join group" on a prepared group whose host is offline, the full group link's plan is `CPError`, so the app shows "Connect via link?" with "Use current profile" / "Use new incognito profile" instead of "Group already exists!". `groupJoinRequestPlan` (`Commands.hs:4767-4785`) does not find the group (`getGroupInfoByGroupLinkHash`, `Store/Groups.hs:2913`, excludes `GSMemUnknown`, the prepared membership's status until the host accepts) but finds the host-member connection (`RcvGroupMsgConnection`) and throws "found connection entity is not RcvDirectMsgConnection"; `contactRequestPlan` has the `RcvGroupMsgConnection _ gInfo _ -> groupPlan gInfo …` branch that this function lacks. Marked `@KnownAppIssue("15.5 item 20")`. + +21. **Member info of a member who left reverts the member to connected** (deterministic; `MemberInfoTest.memberInfo_deleteMessagesOfLeftMember`). Tapping the avatar on a left member's old message passes the item's `chatDir.groupMember` snapshot (`ChatView.kt:2094`, status connected); `APIGroupMemberInfo` fails with `CONN NOT_FOUND` because `xGrpLeave` deleted the connection (`Subscriber.hs:3817`), and the fallback `?: member` (`ChatView.kt:521-536`) upserts the stale snapshot over `MemLeft`. The correct "Delete member messages" layout flashes, then "Remove member" and "Block for all" are offered for a member who has left. Marked `@KnownAppIssue("15.5 item 21")`. + +22. **The selection toolbar is not rechecked when a selected message is deleted by the peer** (deterministic; `MultiSelectTest.select_itemDeletedByPeer_recheck`). `SelectableChatItemToolbars.kt:127-130` reruns `recheckItems` only when `chatInfo`, the `chatItems` list object or the selection change; a received deletion goes through `upsertChatItem`, which replaces the item in place (`ChatModel.kt` ~698), so the list object is the same and Forward stays enabled (and `deleteForEveryoneEnabled` stale) for a message that is now marked deleted. Marked `@KnownAppIssue("15.5 item 22")`. + +23. **The SimpleX Lock notice is not offered to users with more than three chats** (`App.kt:94-105`; `LockDesktopTest.lock_noticeOfferedWithManyChats`, marked `@KnownAppIssue("15.5 item 23", intermittent = true)`). `startChat` sets `localUserCreated = true` (`SimpleXAPI.kt:706`) before `getUserChatData` loads the chats (`:707`); that shows the main screen, whose one-shot `LaunchedEffect(showAdvertiseLAAlert)` tests `chatModel.chats.size > 3` while the list is still empty and never runs again. The more chats, the slower the load, so the users the notice is for are the most likely to miss it. + ### 15.6 Catalogue corrections applied from passing tests (exhaustive pass) - 01:34 and 01:8: accepting conditions enables the operators selected in onboarding, not all preset ones. diff --git a/plans/ui-tests/catalogue/00-legend.md b/plans/ui-tests/catalogue/00-legend.md index eaf997f6b3..b094c10e34 100644 --- a/plans/ui-tests/catalogue/00-legend.md +++ b/plans/ui-tests/catalogue/00-legend.md @@ -123,80 +123,80 @@ Each sub-area (a `###` section) is one test class, or several consecutive classe | 01 | 3 | Chat list | `chatListScrollToTopOnTitleClick` | `ChatListToolbarSearchTest` | 16 | 0 | | 01 | 3 | Chat list | `unreadFilterToggle` | `ChatListFiltersListsTest` | 24 | 0 | | 01 | 3 | Chat list | `previewShowsLastTextMessage` | `ChatListRowsTest` | 17 | 0 | -| 01 | 3 | Chat list | `contactMenuMarkRead` | `ChatListMenusPendingTest` | 12 | 0 | +| 01 | 3 | Chat list | `contactMenuMarkRead` | `ChatListMenusPendingTest` | 11 | 0 | | 01 | 3 | Chat list | `contactRequestMenuAccept` | `ChatListRequestsCardsTest`, `ChatListRequestsCardsCompatTest`, `ChatListRequestsCardsCompatStableTest` | 24 | 0 | -| 01 | 4 | Servers summary | `serversSummaryOpensFromIndicator` | `ServersSummaryTest` | 10 | 0 | -| 01 | 5 | User picker and profiles | `userPickerOpensAndShowsCurrentUser` | `UserPickerTest` | 11 | 0 | -| 01 | 5 | User picker and profiles | `profilesListShowsAllVisible` | `ChatProfilesTest` | 21 | 0 | -| 01 | 5 | User picker and profiles | `editProfileDisplayName` | `ProfileEditTest` | 12 | 0 | -| 01 | 6 | New chat sheet and contacts | `newChatSheetShowsActions` | `NewChatSheetTest` | 17 | 0 | -| 01 | 7 | 1-time link (create / share / QR / incognito / profile) | `createOneTimeLinkShowsLinkAndQr` | `OneTimeLinkTest`, `OneTimeLinkCompatTest`, `OneTimeLinkCompatStableTest` | 17 | 0 | -| 01 | 8 | Paste link and connection plans (contact, invitation, address, bot, name, signature, URI and link-card links) | `pasteLinkInvalidTextAlert` | `ConnectPlanTest`, `ConnectPlanCompatTest`, `ConnectPlanCompatStableTest` | 29 | 0 | -| 01 | 9 | Connecting a prepared chat (compose area) | `preparedContactInvitationConnectButton` | `PreparedChatTest` | 11 | 0 | -| 01 | 10 | Pending connection info screen | `pendingInfoInvitedShowsLinkQrShare` | `PendingConnectionTest` | 7 | 0 | -| 01 | 11 | Address screens | `addressScreenNoAddressShowsCreate` | `AddressTest` | 22 | 0 | +| 01 | 4 | Servers summary | `serversSummaryShowsLocalSmpConnected` | `ServersSummaryTest` | 7 | 0 | +| 01 | 5 | User picker and profiles | `userPickerOpensAndShowsCurrentUser` | `UserPickerTest` | 5 | 0 | +| 01 | 5 | User picker and profiles | `profilesListShowsAllVisible` | `ChatProfilesTest` | 14 | 0 | +| 01 | 5 | User picker and profiles | `editProfileDisplayName` | `ProfileEditTest` | 7 | 0 | +| 01 | 6 | New chat sheet and contacts | `newChatSheetShowsActions` | `NewChatSheetTest` | 10 | 0 | +| 01 | 7 | 1-time link (create / share / QR / incognito / profile) | `createOneTimeLinkShowsLinkAndQr` | `OneTimeLinkTest`, `OneTimeLinkCompatTest`, `OneTimeLinkCompatStableTest` | 14 | 0 | +| 01 | 8 | Paste link and connection plans (contact, invitation, address, bot, name, signature, URI and link-card links) | `pasteLinkInvalidTextAlert` | `ConnectPlanTest` | 15 | 0 | +| 01 | 9 | Connecting a prepared chat (compose area) | `preparedContactInvitationConnectButton` | `PreparedChatTest` | 6 | 0 | +| 01 | 10 | Pending connection info screen | `pendingInfoInvitedSharesWorkingLink` | `PendingConnectionTest` | 5 | 0 | +| 01 | 11 | Address screens | `addressScreenNoAddressShowsCreate` | `AddressTest` | 12 | 0 | | 01 | 12 | SimpleX name | `simplexNameEntryShownWithAddress` | `SimplexNameTest` | 8 | 0 | -| 01 | 13 | Contact info screen | `contactInfoOpensFromChatHeader` | `ContactInfoTest` | 28 | 0 | -| 01 | 14 | Preferences | `contactPrefsDisappearingMessagesAllow` | `ContactPreferencesTest` | 12 | 0 | -| 01 | 15 | Delete contact variants | `deleteContactOnlyConversation` | `DeleteContactTest` | 10 | 0 | -| 02 | A | Chat view shell, toolbar, banner | `directChat_opens_showsToolbarAndBanner` | `ChatShellTest` | 11 | 0 | -| 02 | B | Composing and sending text, links, mentions, commands | `sendText_sendButton_peerReceives` | `ComposeTest` | 28 | 0 | -| 02 | C | Live messages | `liveMessage_menuStart_alertConfirm_peerSeesStarted` | `LiveMessageTest` | 10 | 0 | -| 02 | D | Timed / disappearing messages | `timed_send30s_bothSidesDelete` | `TimedMessageTest` | 6 | 0 | +| 01 | 13 | Contact info screen | `contactInfoSetLocalAlias` | `ContactInfoTest` | 24 | 0 | +| 01 | 14 | Preferences | `contactPrefsSaveNotifiesContactAndEnforces` | `ContactPreferencesTest` | 4 | 0 | +| 01 | 15 | Delete contact variants | `deleteContactOnlyConversation` | `DeleteContactTest` | 7 | 0 | +| 02 | A | Chat view shell, toolbar, banner | `directChat_opens_showsToolbarAndBanner` | `ChatShellTest` | 8 | 0 | +| 02 | B | Composing and sending text, links, mentions, commands | `sendText_sendButton_peerReceives` | `ComposeTest` | 21 | 0 | +| 02 | C | Live messages | `liveMessage_menuStart_alertConfirm_peerSeesStarted` | `LiveMessageTest` | 5 | 0 | +| 02 | D | Timed / disappearing messages | `timed_send30s_bothSidesDelete` | `TimedMessageTest` | 5 | 0 | | 02 | E | Voice messages | `voice_record_send_peerReceivesVoice` | `VoiceMessageTest` | 11 | 0 | -| 02 | F | Attachments – sending | `attach_sheet_showsImageVideoFile` | `SendAttachmentTest`, `SendAttachmentCompatTest`, `SendAttachmentCompatStableTest` | 21 | 1 | -| 02 | G | Receiving files: download, cancel, errors, save, viewers | `rcvImage_autoReceived_renders` | `ReceiveFileTest` | 20 | 0 | -| 02 | H | Replies / quotes | `reply_menu_sendQuoted_peerSeesQuote` | `ReplyTest`, `ReplyCompatTest`, `ReplyCompatStableTest` | 7 | 0 | -| 02 | I | Edit and history | `edit_menu_updatesPeer` | `EditMessageTest`, `EditMessageCompatTest`, `EditMessageCompatStableTest` | 12 | 0 | -| 02 | J | Delete / reveal / blocked | `delete_ownForMe_localOnly` | `DeleteMessageTest`, `DeleteMessageCompatTest`, `DeleteMessageCompatStableTest` | 8 | 0 | -| 02 | K | Multi-select and selection toolbar | `select_enterMode_showsCounterToolbar` | `MultiSelectTest` | 9 | 0 | -| 02 | L | Forwarding | `forward_contactToContact_peerSeesForwarded` | `ForwardTest` | 17 | 0 | -| 02 | M | Reactions | `reaction_add_peerSeesAdded` | `ReactionTest`, `ReactionCompatTest`, `ReactionCompatStableTest` | 8 | 0 | -| 02 | N | Copy / Share / Save / text selection | `copy_text_toClipboardAndToast` | `CopyShareTest` | 9 | 0 | -| 02 | O | Reports, member side | `report_member_spam_moderatorsReceive` | `MemberReportTest` | 7 | 0 | -| 02 | P | Chat item info | `info_sent_showsSentAt` | `ItemInfoTest` | 15 | 0 | -| 02 | Q | Unread handling, scrolling, pagination | `unread_openChat_scrollsToFirstUnread` | `UnreadScrollTest` | 18 | 0 | -| 02 | R | Search and content filters | `search_direct_filtersResults` | `ChatSearchTest` | 11 | 0 | -| 02 | S | Draft persistence | `draft_text_restoredAfterChatSwitch` | `DraftTest` | 8 | 0 | +| 02 | F | Attachments – sending | `attachFile_withText_peerReceivesTextAndFile` | `SendAttachmentTest`, `SendAttachmentCompatTest`, `SendAttachmentCompatStableTest` | 14 | 1 | +| 02 | G | Receiving files: download, cancel, errors, save, viewers | `rcvImage_autoReceived_renders` | `ReceiveFileTest` | 19 | 0 | +| 02 | H | Replies / quotes | `reply_menu_sendQuoted_peerSeesQuote` | `ReplyTest`, `ReplyCompatTest`, `ReplyCompatStableTest` | 6 | 0 | +| 02 | I | Edit and history | `edit_menu_updatesPeer` | `EditMessageTest`, `EditMessageCompatTest`, `EditMessageCompatStableTest` | 5 | 0 | +| 02 | J | Delete / reveal / blocked | `delete_ownForEveryone_peerSeesMarkedDeleted` | `DeleteMessageTest`, `DeleteMessageCompatTest`, `DeleteMessageCompatStableTest` | 4 | 0 | +| 02 | K | Multi-select and selection toolbar | `select_toggle_updatesCounter` | `MultiSelectTest` | 3 | 0 | +| 02 | L | Forwarding | `forward_contactToContact_peerSeesForwarded` | `ForwardTest` | 9 | 0 | +| 02 | M | Reactions | `reaction_add_peerSeesAdded` | `ReactionTest`, `ReactionCompatTest`, `ReactionCompatStableTest` | 5 | 0 | +| 02 | N | Copy / Share / Save / text selection | `copy_text_toClipboardAndToast` | `CopyShareTest` | 5 | 0 | +| 02 | O | Reports, member side | `report_member_spam_moderatorsReceive` | `MemberReportTest` | 2 | 0 | +| 02 | P | Chat item info | `info_sent_showsSentAt` | `ItemInfoTest` | 10 | 0 | +| 02 | Q | Unread handling, scrolling, pagination | `unread_openChat_scrollsToFirstUnread` | `UnreadScrollTest` | 12 | 0 | +| 02 | R | Search and content filters | `search_direct_filtersResults` | `ChatSearchTest` | 6 | 0 | +| 02 | S | Draft persistence | `draft_onlyLastChatKept` | `DraftTest` | 5 | 0 | | 02 | T | Status icons and delivery | `status_direct_sentThenDelivered` | `ItemStatusTest`, `ItemStatusCompatTest`, `ItemStatusCompatStableTest` | 9 | 0 | -| 02 | U | Special items | `e2eeInfo_direct_pq` | `SpecialItemTest`, `SpecialItemCompatTest`, `SpecialItemCompatStableTest` | 19 | 1 | -| 02 | V | Private notes | `notes_sendText_saved` | `NotesTest` | 7 | 0 | -| 02 | W | Context menus and keyboard | `contextMenu_rightClick_opens` | `ContextMenuTest` | 5 | 0 | +| 02 | U | Special items | `featureOffer_accept_updatesPrefs` | `SpecialItemTest`, `SpecialItemCompatTest`, `SpecialItemCompatStableTest` | 16 | 1 | +| 02 | V | Private notes | `notes_sendText_saved` | `NotesTest` | 5 | 0 | +| 02 | W | Context menus and keyboard | `contextMenu_itemsMatrix_byItemType` | `ContextMenuTest` | 1 | 0 | | 02 | X | Rare states and out-of-reach rows | `call_webrtcSession_connects` | `ChatEdgeCasesTest` | 4 | 2 | -| 03 | 1 | Create group | `createGroup_buttonDisabledForEmptyOrInvalidName` | `CreateGroupTest` | 12 | 0 | -| 03 | 2 | Invite members | `inviteMembers_listsOnlyEligibleContacts` | `InviteMembersTest`, `InviteMembersCompatTest`, `InviteMembersCompatStableTest` | 11 | 0 | -| 03 | 3 | Group info screen rows | `groupInfo_headerShowsProfileAndCopiesName` | `GroupInfoTest` | 13 | 0 | -| 03 | 4 | Group profile and welcome message | `editGroupProfile_renameNotifiesMembers` | `GroupProfileTest` | 14 | 0 | -| 03 | 5 | Group preferences | `groupPrefs_disappearingOnWithTtl` | `GroupPreferencesTest` | 19 | 1 | -| 03 | 6 | Member admission and review | `memberAdmission_enableReviewAll` | `MemberAdmissionTest` | 16 | 2 | -| 03 | 7 | Group link | `groupLink_createdAutomatically` | `GroupLinkTest` | 14 | 0 | -| 03 | 8 | Joining (alice as joiner), connect-plan states, invitations | `joinGroup_pasteShortLinkPrepareAndJoin` | `JoiningTest`, `JoiningCompatTest`, `JoiningCompatStableTest` | 24 | 0 | -| 03 | 9 | Member list and multi-select | `memberList_rolesAndStatuses` | `MemberListTest` | 10 | 0 | -| 03 | 10 | Member info | `memberInfo_openShowsSections` | `MemberInfoTest` | 28 | 0 | -| 03 | 11 | Member contacts | `memberContact_createAndSendInvitation` | `MemberContactTest` | 5 | 0 | -| 03 | 12 | Support chats, «Chats with members» / «Chat with admins» | `supportChat_memberSendsToAdmins` | `SupportChatTest` | 13 | 0 | -| 03 | 13 | Reports, moderator side | `reports_moderatorBannerAndCounts` | `ModeratorReportTest` | 9 | 0 | -| 03 | 14 | Moderation | `moderate_markDeletedForAll` | `ModerationTest` | 6 | 0 | -| 03 | 15 | Mentions, notification side | `mentions_mentionsOnlyMuteStillNotifies` | `MentionNotificationTest` | 2 | 0 | -| 03 | 16 | Group events and history | `groupEvents_memberConnectedMerged` | `GroupEventsTest` | 8 | 0 | -| 03 | 17 | Leave, delete, clear | `leaveGroup_notifiesMembers` | `LeaveDeleteClearTest` | 8 | 0 | +| 03 | 1 | Create group | `createGroup_buttonDisabledForEmptyOrInvalidName` | `CreateGroupTest` | 9 | 0 | +| 03 | 2 | Invite members | `inviteMembers_listsOnlyEligibleContacts` | `InviteMembersTest`, `InviteMembersCompatTest`, `InviteMembersCompatStableTest` | 8 | 0 | +| 03 | 3 | Group info screen rows | `groupInfo_headerShowsProfileAndCopiesName` | `GroupInfoTest` | 11 | 0 | +| 03 | 4 | Group profile and welcome message | `editGroupProfile_renameNotifiesMembers` | `GroupProfileTest` | 10 | 0 | +| 03 | 5 | Group preferences | `groupPrefs_ownerChangesNotifyAndRestrictMembers` | `GroupPreferencesTest` | 6 | 1 | +| 03 | 6 | Member admission and review | `memberAdmission_unsavedChangesAlert` | `MemberAdmissionTest` | 7 | 2 | +| 03 | 7 | Group link | `groupLink_cliJoinsViaShortLink` | `GroupLinkTest` | 9 | 0 | +| 03 | 8 | Joining (alice as joiner), connect-plan states, invitations | `joinGroup_pasteShortLinkPrepareAndJoin` | `JoiningTest`, `JoiningCompatTest`, `JoiningCompatStableTest` | 17 | 0 | +| 03 | 9 | Member list and multi-select | `memberList_rolesAndStatuses` | `MemberListTest` | 5 | 0 | +| 03 | 10 | Member info | `memberInfo_openShowsSections` | `MemberInfoTest` | 22 | 0 | +| 03 | 11 | Member contacts | `memberContact_createAndSendInvitation` | `MemberContactTest` | 4 | 0 | +| 03 | 12 | Support chats, «Chats with members» / «Chat with admins» | `supportChat_memberSendsToAdmins` | `SupportChatTest` | 6 | 0 | +| 03 | 13 | Reports, moderator side | `reports_moderatorBannerAndCounts` | `ModeratorReportTest` | 5 | 0 | +| 03 | 14 | Moderation | `moderate_markDeletedForAll` | `ModerationTest` | 4 | 0 | +| 03 | 15 | Mentions, notification side | `mentions_mentionsOnlyMuteCountsOnlyMentionsForOtherProfile` | `MentionNotificationTest` | 2 | 0 | +| 03 | 16 | Group events and history | `groupEvents_memberConnectedMerged` | `GroupEventsTest` | 6 | 0 | +| 03 | 17 | Leave, delete, clear | `leaveGroup_thenDeleteLocalCopy` | `LeaveDeleteClearTest` | 7 | 0 | | 03 | 18 | Channels: alice as owner, with relays and CLI subscribers | `createChannel_noRelaysBlocksCreation` | `ChannelOwnerCreateTest` | 16 | 0 | | 03 | 18 | Channels: alice as owner, with relays and CLI subscribers | `channel_broadcastPlaceholderAndDelivery` | `ChannelOwnerBroadcastTest` | 15 | 0 | | 03 | 18 | Channels: alice as owner, with relays and CLI subscribers | `channelSubscribers_listAndCount` | `ChannelOwnerManageTest` | 27 | 1 | | 03 | 19 | Channels: alice as subscriber, with a CLI owner and relays | `joinChannel_pasteLinkOpenChannel` | `ChannelSubscriberTest` | 26 | 0 | -| 03 | 20 | Business chats | `business_customerConnectsCreatesBusinessChat` | `BusinessChatTest` | 16 | 0 | -| 04 | A | Settings root and global keys | `settingsRoot_listsDesktopItems` | `SettingsRootTest` | 7 | 0 | -| 04 | B | Privacy (Your privacy, More privacy, receipts) | `privacy_sectionsAndFooter` | `PrivacyTest` | 24 | 0 | -| 04 | C | SimpleX Lock (desktop passcode) and self-destruct | `lock_enable_setsPasscode` | `LockTest` | 22 | 0 | -| 04 | D | Appearance | `appearance_language_switch` | `AppearanceTest` | 24 | 0 | -| 04 | E | Network & servers | `network_rootLayout` | `NetworkServersTest` | 22 | 0 | -| 04 | E | Network & servers | `socks_enable_confirm` | `NetworkProxyTest` | 21 | 0 | +| 03 | 20 | Business chats | `business_autoReplyWelcome` | `BusinessChatTest` | 9 | 0 | +| 04 | A | Settings root and global keys | `settingsRoot_rowsOpenTheirScreens` | `SettingsRootTest` | 5 | 0 | +| 04 | B | Privacy (Your privacy, More privacy, receipts) | `privacy_sectionsAndFooter` | `PrivacyTest` | 22 | 0 | +| 04 | C | SimpleX Lock (desktop passcode) and self-destruct | `lock_enable_setsPasscode` | `LockTest` | 12 | 0 | +| 04 | D | Appearance | `appearance_language_switch` | `AppearanceTest` | 18 | 0 | +| 04 | E | Network & servers | `operators_enableDisableOperator` | `NetworkServersTest` | 18 | 0 | +| 04 | E | Network & servers | `socks_enable_confirm` | `NetworkProxyTest` | 15 | 0 | | 04 | F | Audio & video calls | `calls_screenLayout_noLockScreenOption` | `CallSettingsTest` | 6 | 0 | -| 04 | G | Chat data, database, encryption, startup DB errors | `chatData_layout` | `ChatDataTest` | 21 | 2 | -| 04 | G | Chat data, database, encryption, startup DB errors | `dbEncrypt_setFirstPassphrase` | `DatabaseEncryptionTest` | 14 | 1 | +| 04 | G | Chat data, database, encryption, startup DB errors | `chatData_ttlDecrease_alertConfirm` | `ChatDataTest` | 12 | 1 | +| 04 | G | Chat data, database, encryption, startup DB errors | `dbEncrypt_changePassphrase` | `DatabaseEncryptionTest` | 7 | 1 | | 04 | H | Migrate to/from device | `migrateFrom_stopsChatAndAuth` | `MigrationTest` | 15 | 6 | -| 04 | I | App version, update checker, developer tools | `version_screenShowsVersions` | `VersionDevToolsTest` | 15 | 0 | -| 04 | J | Help & support | `help_layout` | `HelpTest` | 8 | 2 | +| 04 | I | App version, update checker, developer tools | `version_screenShowsVersions` | `VersionDevToolsTest` | 13 | 0 | +| 04 | J | Help & support | `help_layoutHowToUseAndAbout` | `HelpTest` | 4 | 2 | | 04 | K | Remote desktop: "Link a mobile" (the desktop GUI is the controller, the CLI is the mobile host) | `linkMobile_layout` | `RemoteDesktopTest` | 17 | 0 | | 04 | L | Supporter perks (badge-display screens excluded) | `badges_supportScreenLayout` | `SupporterPerksTest` | 8 | 1 | | 04 | M | Out of reach in-process | `tray_closeToTrayBehaviour` | `SettingsOutOfReachTest` | 8 | 8 | diff --git a/plans/ui-tests/catalogue/01-onboarding-chatlist-connect-profiles.md b/plans/ui-tests/catalogue/01-onboarding-chatlist-connect-profiles.md index cc85b3ee2b..607d68bf5e 100644 --- a/plans/ui-tests/catalogue/01-onboarding-chatlist-connect-profiles.md +++ b/plans/ui-tests/catalogue/01-onboarding-chatlist-connect-profiles.md @@ -1,6 +1,6 @@ # UI test catalogue 1: onboarding, chat list, connecting, profiles, address, contact info -Rows follow `00-legend.md` (paths, row format, Needs codes, users and CLI output, ownership). Each row has the exact strings.xml labels, file:line, test names, the GUI and CLI checks with matching Haskell tests, and blockers. Totals: **331 test cases, none of them `@Ignore`**, in 15 sections (details in `## Count` at the end). +Rows follow `00-legend.md` (paths, row format, Needs codes, users and CLI output, ownership). Each row has the exact strings.xml labels, file:line, test names, the GUI and CLI checks with matching Haskell tests, and blockers. Totals: **312 test cases, none of them `@Ignore`**, in 15 sections (details in `## Count` at the end). ## Setup every test needs 1. **Compose the real root** (`App.kt` → `AppScreen`/`MainPage`) so that `AlertManager.shared`/`privacySensitive.showInView()` (`C/App.kt:242,255`) render alerts. Alerts are material `AlertDialog` popups and the menus are `DropdownMenu` popups; both can be found with `onAllNodes`. `DefaultDialog` (a separate AWT window, `D/views/helpers/DefaultDialog.desktop.kt:25`) is defined but never used, so it doesn't block anything. @@ -49,7 +49,7 @@ Rows follow `00-legend.md` (paths, row format, Needs codes, users and CLI output | Startup | What's New | Automatic 1 s after the chat list when the stored `whatsNewVersion` differs from the last version (including unset); Settings → "Help & support" → "What's new" | `C/views/onboarding/WhatsNewView.kt:68,1030`; `ChatListView.kt:219-228` | `whatsNewNotShownForNewUser` | The onboarding profile screen calls `setLastVersionDefault` when it is composed (`WelcomeView.kt:345-347`; the mobile layout at :305-307), so after the chat list has been shown for 2 s (the auto-open fires 1 s after its first composition, `ChatListView.kt:225`) no What's New modal is open | ONBOARD | – | | Startup | Updated conditions | What's New with "View updated conditions": as in 04 `operators_reviewAcceptConditions`, the console sends `/_servers ` with the SimpleX Chat operator `enabled: true`, then `restart()`; the core returns `UCAReview` with `showNotice` while the conditions were never notified (`src/Simplex/Chat/Operators.hs:117-129`), and the chat list opens What's New with `updatedConditions` after 1 s (`ChatListView.kt:219-228`) | `WhatsNewView.kt:72-81,197-216` | `whatsNewShowsViewUpdatedConditions` | What's New shows "View updated conditions"; opening it already calls `setConditionsNotified` (`/_conditions_notified`, `WhatsNewView.kt:71-79`), so the core's `currentConditions.notifiedAt` is set; the link opens `UsageConditionsView` in the Review state, titled "Updated conditions" with "Accept conditions" (`NetworkAndServers.kt:790-805`) | RESTART | `conditionsAction.shouldShowNotice` is core state | -### 3. Chat list (93 tests) +### 3. Chat list (92 tests) | Area | Feature | Entry path | Main file:line | Test case(s) | Verification | Needs | Notes | |---|---|---|---|---|---|---|---| @@ -60,7 +60,7 @@ Rows follow `00-legend.md` (paths, row format, Needs codes, users and CLI output | Toolbar | Subscription indicator | Icon next to "Chats" | `ChatListView.kt:668`; `ServersSummaryView.kt:120,349` | `subscriptionIndicatorServerDown` | Stopping the local SMP server lowers the subscribed share | SRVCTL | 1 s polling. alice first creates a 1-time link so a queue is subscribed; with no subscriptions and no session the indicator stays in the fresh-install state (`ServersSummaryView.kt:92-94`) | | Toolbar | New chat | Floating button "Start new chat" (bottom bar in one-hand mode) | `ChatListView.kt:102,479-501,585-605` | `newChatButtonOpensNewChatSheet` | With "Reachable app toolbars" on (Pref OneHandUI, default true, `SimpleXAPI.kt:275`) there is no FAB and the bottom-bar button "Start new chat" opens the "New chat" sheet (`ChatListView.kt:241-249,585-605`); with it off, the FAB "Start new chat" opens it (`:251-264,479-501`); with chat stopped neither button exists (the FAB needs `chatRunning == true`, :262, and the bar button is added only in the `else` branch of `if (stopped)`, :545,560,569,585-605), so the FAB's grey disabled state (:482,497) is unreachable | — | – | | Search | Text search | "Search or paste SimpleX link" | `ChatListView.kt:788,1541-1569` | `searchFiltersByDisplayName`, `searchFiltersByAlias`, `searchMatchesPendingConnectionAlias`, `searchNoChatsFound`, `searchClearsWhenFilterChanges` | Filters on `anyNameContains` and the pending-connection alias (a pending connection matches only its alias, `ChatListView.kt:1563`). Empty result shows "No chats found". Changing filter clears the text (`ChatListView.kt:1132`); while text is entered the unread toggle is hidden (:811-824), so the filter is changed with a preset or list chip. The open chat is always listed whatever the search (:1554,1557). Haskell: `testFilterSearch` (ChatList.hs) | P | – | -| Search | Paste link | Paste a SimpleX link into search | `ChatListView.kt:836-845,887` | `searchPasteLinkTriggersConnectPlan`, `searchPasteKnownContactLinkFiltersToContact`, `searchPasteKnownGroupLinkFiltersToGroup` | Field shows the link's description, e.g. "SimpleX contact address (via )" or "SimpleX group link (via )" (`simplexLinkText`, `ChatModel.kt:5157-5160`), and becomes read-only (disabled, :804). A known contact/group filters the list to that chat with no alert; an unknown link with profile data shows the open-chat alert (profile name, "Open new chat", "Cancel"), and "Cancel" clears the field (§8) | P | The app reacts to any text change (`ChatListView.kt:836-845`), so the tests enter the link text directly (Compose's own paste reads `LocalClipboard`, which the harness does not provide) | +| Search | Paste link | Paste a SimpleX link into search | `ChatListView.kt:836-845,887` | `searchPasteLinkTriggersConnectPlan`, `searchPasteKnownContactLinkFiltersToContact`, `searchPasteKnownGroupLinkFiltersToGroup` | Field shows the link's description, e.g. "SimpleX contact address (via )" or "SimpleX group link (via )" (`simplexLinkText`, `ChatModel.kt:5157-5160`), and becomes read-only (disabled, :804). A known contact/group filters the list to that chat with no alert; an unknown link with profile data shows the open-chat alert (profile name, "Open new chat", "Cancel"), and "Cancel" clears the field (§8); `searchPasteLinkTriggersConnectPlan` then has bob delete the address (`/da`, `View.hs:1123-1127`) and pastes it again: the plan fails with SMP AUTH and the alert "Connection link removed" / "Your contact removed this link, or it was a one-time link that was already used.…" (`connection_error_auth`, `C/model/SimpleXAPI.kt:1757-1764`) shows with no plan alert; "OK" clears the field and no chat with bob is created (covers §8 "Deleted link → error") | P | The app reacts to any text change (`ChatListView.kt:836-845`), so the tests enter the link text directly (Compose's own paste reads `LocalClipboard`, which the harness does not provide) | | Search | Name candidate | Type `bobby` or `@bob.simplex` (bare words need 5+ characters) | `ChatListView.kt:1152,1168,1207` | `searchNameShowsConnectToNameRow` | Row "Connect to bobby.testing" / "Connect to @bob.simplex"; "bob" shows no row | — | The row comes from local parsing (`ChatListView.kt:846-847,1158-1161`), with no peer or names server | | Search | Name candidate | Type `bobby` (bare words of 5+ characters get `.testing`) or `@bob.simplex` | `ChatListView.kt:1152,1168,1207` | `searchKnownNameFiltersChat` | A known name filters to that chat. Haskell: `Names.hs` `testConnectByName` | P, NAMES | NAMES: `uitest-servers` names mode with the mock registry (plan §8.2); GUI servers need the names role (`enableNamesRole`, `tests/ChatClient.hs:488`) | | Filters | Unread | Filter icon next to search; empty state "No unread chats" (tap to clear) | `ChatListView.kt:747,1244-1257` | `unreadFilterToggle`, `unreadFilterEmptyState` | Shows only chats with `unreadTag` (`C/model/ChatModel.kt:1460-1464`): unread messages in unmuted chats, unread mentions in mentions-only groups, and "Mark unread" chats; a muted chat with unread messages is hidden (checked once that chat's row shows the unread count with the filter still off, then the filter is turned on, `ChatPreviewView.kt:460-498`, `ChatModel.kt:1460-1464`: the CLI prints its message when it is sent, before it reaches alice, and a chat without unread messages would be hidden anyway). Empty state "No unread chats" (tap clears the filter). Haskell: `testFilterUnread`, `testFilterFavoriteOrUnread` | P | Icon has no description | @@ -85,7 +85,7 @@ Rows follow `00-legend.md` (paths, row format, Needs codes, users and CLI output | Contact menu | Prepared contact | Row of a prepared contact before "Connect" | `ChatListNavLinkView.kt:279-299` | `contactMenuForNotReadyContactOnlyDelete` | A prepared contact (link typed into the chat-list search → "Open new chat", before "Connect") has no active connection, so the menu shows only "Delete" (`ChatListNavLinkView.kt:288`) | P | The link is typed into the chat-list search (`ChatListView.kt:837-844`). "Delete" opens "Confirm contact deletion?" (`C/views/chat/ChatInfoView.kt:231-246,421-426`) | | Group menu | Mute modes | Row menu "Mute"/"Mute all"/"Unmute" | `ChatListNavLinkView.kt:321-351` | `groupMenuMuteModesAllMentionsNone` | Menu label "Mute" moves All→Mentions, then "Mute all" moves Mentions→None, then "Unmute" moves None→All (`hasMentions` is true for every group, `C/model/ChatModel.kt:1830`); the row icon follows the mode. Haskell: `testMuteGroup`. Leave, delete and archive reports from this menu are in catalogue 03 | P | – | | Notes menu | Notes | "Mark unread", "Clear" → "Clear private notes?" | `ChatListNavLinkView.kt:355,449` | `notesMenuMarkUnreadAndClear` | With one note sent the menu offers "Mark unread" and "Clear" (`ChatListNavLinkView.kt:355-362`); "Mark unread" sets the unread mark; "Clear" shows "Clear private notes?" / "All messages will be deleted - this cannot be undone!" (`C/views/chat/ChatInfoView.kt:508-516`), and its "Clear" empties the notes and resets the chat stats, the unread mark included (`C/model/ChatModel.kt:803-816`). Haskell: `testNotes` (`tests/ChatTests/Local.hs:48` "/_unread chat *1 on"); `testFiles` (`Local.hs:164-165` "/clear *" → "notes: all messages are removed") | — | – | -| Pending conn | Row and menu | Row after creating a 1-time link; menu "Set contact name", "Delete"; click | `ContactConnectionView.kt:23`; `ChatListNavLinkView.kt:143-162,582,775` | `pendingConnectionMenuSetName`, `pendingConnectionClickOpensInfo` | `pendingConnectionClickOpensInfo`: the row reads "invited to connect" / "you shared one-time link" (`ChatModel.kt:3251,3266`; its add-link icon, `ContactConnectionView.kt:26`, has the same description "profile image placeholder" as the link icon, so the texts tell the two apart), and a click opens the §10 screen "You invited a contact" with "One-time invitation link" and the name field, unfocused (`ChatView.kt:827`); `pendingConnectionMenuSetName`: "Set contact name" opens the same screen with the name field focused (`focusAlias = true`, `ChatListNavLinkView.kt:590`), and the name typed there renames the row | — | – | +| Pending conn | Row and menu | Row after creating a 1-time link; menu "Set contact name", "Delete"; click | `ContactConnectionView.kt:23`; `ChatListNavLinkView.kt:143-162,582,775` | `pendingConnectionMenuSetName` | `pendingConnectionMenuSetName`: "Set contact name" opens the same screen with the name field focused (`focusAlias = true`, `ChatListNavLinkView.kt:590`), and the name typed there renames the row | — | `pendingConnectionClickOpensInfo` merged into `pendingInfoInvitedSharesWorkingLink` (row texts "invited to connect" / "you shared one-time link" are no longer checked) | | Pending conn | Row and menu | Row after creating a 1-time link; menu "Set contact name", "Delete"; click | `ContactConnectionView.kt:23`; `ChatListNavLinkView.kt:143-162,582,775` | `pendingConnectionMenuDeleteInitiated` | "Delete pending connection?" with "The contact you shared this link with will NOT be able to connect!"; the CLI first repeats `/_connect plan 1 ` until it prints `error: connection authorization failed - …` (the GUI's deletion reaches the server later, in a background thread, simplexmq `Agent.hs:1013-1015`; the plan reads the link from the server, `Library/Commands.hs:4431`), then a CLI `/c` with the deleted link prints "error: connection authorization failed…". Haskell: `testShortLinkDeletedInvitation` (H/Profiles.hs:4268) for deletion; `testPlanShortLinkInvitation` (H/Profiles.hs:3895-3932) for "/_connect plan" and "/c" printing "error: connection authorization failed" on a link the server no longer serves (View.hs:2922-2924); before the deletion reaches the server the plan prints "invitation link: ok to connect" (View.hs:2241) | P | – | | Pending conn | Accepted connection row | Row after alice pastes the CLI's full 1-time link ("The invitation link for old clients:") and taps "Use current profile" while the CLI is stopped, then dismisses "Connection request sent!"; menu "Delete" | `ContactConnectionView.kt:26`; `ChatListNavLinkView.kt:582,775`; `ConnectPlan.kt:103-113,417-421` | `pendingAcceptedRowIcon`, `pendingConnectionMenuDeleteAccepted` | The row reads "accepted invitation" / "via one-time link" (`ChatModel.kt:3252,3273`; the link icon, `ContactConnectionView.kt:26`, has the same description as the add-link icon); delete shows "The connection you accepted will be cancelled!" | P, PEERCTL, CB | The connection stays pending only while the link's creator is stopped. A short link would create a prepared contact instead. The current-build peer is started with `--show-full-links` (`src/Simplex/Chat/Options.hs:435`) | | Requests | Contact request (contact row) | CLI `/c ` → a contact row with "Open to accept"; row menu "Accept" / "Reject" | `C/views/chatlist/ChatPreviewView.kt:192`; `C/views/chatlist/ChatListNavLinkView.kt:279-286,527-557`; core `src/Simplex/Chat/Store/ContactRequest.hs:84-107,195-215` | `contactRequestMenuAccept`, `contactRequestMenuReject` | Every new request creates a contact (`createRequestEntity` → `createContact'`), shown as a contact row with "Open to accept", which both tests wait for once CLI `/c
` prints "connection request sent!". Accept: CLI "alice: contact is connected". Reject: the row is removed and the CLI prints nothing (its next command, `/u`, prints "user profile: bob"). Haskell: `testUserContactLink` (1362), `testRejectContactAndDeleteUserContact` (1817) | P, S | – | @@ -102,134 +102,134 @@ Rows follow `00-legend.md` (paths, row format, Needs codes, users and CLI output | Center pane | Placeholder | No chat selected | `C/App.kt:407-416` | `noSelectedChatPlaceholder` | "No selected chat" | — | – | | Empty | Empty list | A user with no chats | `ChatListView.kt:449-462` | `youHaveNoChatsEmptyState` | "You have no chats" | FIXTURE (user without chats) | Every new user gets a notes folder and the "Ask SimpleX Team" card (core `src/Simplex/Chat/Library/Commands.hs:470-478`), so the empty list needs a fixture database | -### 4. Servers summary (10 tests) +### 4. Servers summary (7 tests) | Area | Feature | Entry path | Main file:line | Test case(s) | Verification | Needs | Notes | |---|---|---|---|---|---|---|---| -| Servers | Servers info | Click the subscription indicator → "Servers info"; "Reconnect all servers" | `C/views/chatlist/ChatListView.kt:638-655`; `C/views/chatlist/ServersSummaryView.kt:691,929` | `serversSummaryOpensFromIndicator`, `serversSummaryReconnectAll` | "Servers info" opens ("No info, try to reload" before stats load); "Reconnect all servers" → "Reconnect servers?" → confirm, no error alert | — | – | -| Servers | Servers info with traffic | "Servers info"; "Messages" tab; "Showing info for" dropdown "All profiles"/"Current profile" (2+ visible users only); "Reset all statistics"; server row → "SMP server" → "Reconnect"; "Details" | `C/views/chatlist/ServersSummaryView.kt:691,706-712,389,958,592,652` | `serversSummaryShowsLocalSmpConnected`, `serversSummaryAllVsCurrentProfile`, `serversSummarySmpServerDetail`, `serversSummaryDetailedStats`, `serversSummaryReconnectServer`, `serversSummaryResetStats` | `serversSummaryShowsLocalSmpConnected`: local SMP listed under "Connected servers" with message counts; `serversSummaryAllVsCurrentProfile`: with one user there is no "Showing info for" row (`ServersSummaryView.kt:708-713`); once the second, connection-less user is active, "All profiles" (the default, :694) still lists the local SMP server under "Connected servers", and "Current profile" lists no servers and shows "-" for "Messages sent" (`currentUserSMP`, :828-849; `numOrDash`, `Section.kt:381`); `serversSummarySmpServerDetail`: clicking the server row opens "SMP server" with "Server address" (the local server) and "Statistics" "Messages sent"/"Messages received" (:177-190, 558-611); `serversSummaryDetailedStats`: "Details" opens "Detailed statistics" with "Sent messages" › "Sent total" and "Received messages" › "Received total" (:313-317, 452-470); `serversSummaryReconnectServer`: "Reconnect server?"; `serversSummaryResetStats`: "Reset all statistics?" / "Reset" zeroes the counters | P | The test connects a CLI peer and exchanges messages first, because a server is listed only once it has sessions, subscriptions or stats (`src/Simplex/Chat/Stats.hs:216-229`); `serversSummaryAllVsCurrentProfile` also creates a second user | +| Servers | Servers info | Click the subscription indicator → "Servers info"; "Reconnect all servers" | `C/views/chatlist/ChatListView.kt:638-655`; `C/views/chatlist/ServersSummaryView.kt:694,932` | | "Servers info" opens ("No info, try to reload" before stats load); "Reconnect all servers" → "Reconnect servers?" → confirm, no error alert | — | `serversSummaryOpensFromIndicator` merged into `serversSummaryShowsLocalSmpConnected`; `serversSummaryReconnectAll` merged into `serversSummaryResetStatsAndReconnectAll` | +| Servers | Servers info with traffic | "Servers info"; "Messages" tab; "Showing info for" dropdown "All profiles"/"Current profile" (2+ visible users only); "Reset all statistics"; server row → "SMP server" → "Reconnect"; "Details" | `C/views/chatlist/ServersSummaryView.kt:694,709-715,179-194,304-322,932-979,562-588` | `serversSummaryShowsLocalSmpConnected`, `serversSummaryAllVsCurrentProfile`, `serversSummaryDetailedStats`, `serversSummaryReconnectServer`, `serversSummaryResetStatsAndReconnectAll` | `serversSummaryShowsLocalSmpConnected`: "Servers info" is absent before the indicator is clicked; after bob's message the local SMP host (key `Ui.ServersSummary.SmpServer(host)`, host parsed from the configured server) is listed under "Connected servers", and "Messages sent", "Messages received" and "Active connections" are counts, not "-"; clicking the server row opens "SMP server" with "Server address" (the local host) and its own "Messages received"/"Active connections" counts; `serversSummaryAllVsCurrentProfile`: with one user there is no "Showing info for" row (`ServersSummaryView.kt:709-715`); once the second, connection-less user is active, "Showing info for" (key `Ui.ServersSummary.UserCategory`, :815-827) reads "All profiles" (the default, :697) and still lists the local SMP server under "Connected servers" with alice's sent messages counted, and "Current profile" lists no servers and shows "-" for "Messages sent" (`currentUserSMP`, :831-833; `numOrDash`, `Section.kt:381`); `serversSummarySmpServerDetail`: clicking the server row opens "SMP server" with "Server address" (the local server) and "Statistics" "Messages sent"/"Messages received" (:177-190, 558-611); `serversSummaryDetailedStats`: "Details" opens "Detailed statistics" with "Sent messages" › "Sent total" and "Received messages" › "Received total" (:313-317, 452-470); `serversSummaryReconnectServer`: "Reconnect server?"; `serversSummaryResetStatsAndReconnectAll`: "Reset all statistics?" ("Servers statistics will be reset - this cannot be undone!") / "Reset" turns "Messages sent"/"Messages received" to "-" and "Details" › "Subscribed" to "-"; then "Reconnect all servers" → "Reconnect servers?" ("Reconnect all connected servers to force message delivery. It uses additional traffic.") → confirm; bob's next message is counted under "Messages received", no "Error reconnecting servers", and "Details" › "Subscribed" counts the resubscription (agent `processSubResults` increments `connSubscribed`) | P | The test connects a CLI peer and exchanges messages first, because a server is listed only once it has sessions, subscriptions or stats (`src/Simplex/Chat/Stats.hs:216-229`); `serversSummaryAllVsCurrentProfile` also creates a second user; `serversSummarySmpServerDetail` merged into `serversSummaryShowsLocalSmpConnected`; `serversSummaryResetStats` merged into `serversSummaryResetStatsAndReconnectAll` | | Servers | Servers info, files | Click the subscription indicator → "Servers info" → "Files" | `C/views/chatlist/ServersSummaryView.kt:691,876-906` | `serversSummaryFilesTabShowsXftp` | After alice sends a file to the CLI peer (she attaches it by typing its path, `D/platform/PlatformTextField.desktop.kt:97-110`), the "Files" tab lists the local XFTP server | P, XFTP | The file transfer has to come first: an unused XFTP server is not listed (`src/Simplex/Chat/Stats.hs:260-263`) | | Servers | Servers info share | Click the subscription indicator → "Servers info" → share button | `C/views/chatlist/ChatListView.kt:641-652` | `serversSummaryShareJson` | Share copies the pretty-printed `PresentedServersSummary` JSON to the clipboard stub | CB | The share button appears only when Servers info is opened after a summary was loaded: open it, wait for the stats, close it, then reopen it | -### 5. User picker and profiles (44 tests) +### 5. User picker and profiles (26 tests) | Area | Feature | Entry path | Main file:line | Test case(s) | Verification | Needs | Notes | |---|---|---|---|---|---|---|---| -| Picker | Open, current user | Click the profile button | `C/views/chatlist/UserPicker.kt:46,211-215`; `D/views/chatlist/UserPicker.desktop.kt:90` | `userPickerOpensAndShowsCurrentUser`, `userPickerCurrentUserOpensProfileEditor` | Current profile shown; clicking it opens "Your current profile" | — | Clicking outside closes the picker | +| Picker | Open, current user | Click the profile button | `C/views/chatlist/UserPicker.kt:164-181,211-215,222-302,378-417`; `D/views/chatlist/UserPicker.desktop.kt:90` | `userPickerOpensAndShowsCurrentUser` | The picker shows the current user's row with "alice" and the rows "Create SimpleX address", "Chat preferences", "Your chat profiles", "Link a mobile" (Android: "Use from desktop") and "Settings"; clicking the current user opens "Your current profile" with "alice" in the name field and closes the picker (`UserPicker.kt:174-180`); "Chat preferences" opens "Your preferences" (`UserPicker.kt:240-256`; `Preferences.kt:73`); "Settings" opens "Your settings" (`UserPicker.kt:409-417`; `SettingsView.kt:102`); on desktop "Link a mobile" opens the screen with "This device name" (`UserPicker.kt:396-407`; `C/views/remote/ConnectMobileView.kt:96-97`). Each row closes the picker | — | Clicking outside closes the picker; `userPickerCurrentUserOpensProfileEditor`, `userPickerChatPreferencesRow`, `userPickerLinkAMobileRow` and `userPickerSettingsRow` merged into `userPickerOpensAndShowsCurrentUser`; Android opens no "Use from desktop" screen (it starts desktop discovery), only its label is checked | | Picker | Switch profile | Inactive avatars row (desktop) | `UserPicker.kt:161-176,238-249`; `UserPicker.desktop.kt:27` | `userPickerSwitchProfile` | Active user changes; the chat list reloads. Haskell: `testCreateSecondUser` (H/Direct.hs:1968) | — | Avatars have no text; match by display name. The second user is created in the test | -| Picker | Unread per user, muted user | Badge on inactive avatars | `UserPicker.desktop.kt:64-66`; `UserPicker.kt:639` | `userPickerUnreadBadgePerUser`, `userPickerMutedUserGrayBadge` | Count matches the CLI messages sent to the second user; grey when muted | P | – | -| Picker | Option rows | "Create SimpleX address"/"Your SimpleX address", "Chat preferences", "Your chat profiles", "Link a mobile", "Settings" | `UserPicker.kt:219-302,348-396` | `userPickerAddressRowLabelCreateVsYour`, `userPickerChatPreferencesRow`, `userPickerLinkAMobileRow`, `userPickerSettingsRow`, `userPickerDisabledWhenChatStopped`, `userPickerHiddenUsersNotListed` | Labels change with state ("Your SimpleX address" once an address exists; "Linked mobiles" after a pairing is tested in 04 §K); with chat stopped the current-user, address, "Chat preferences", "Your chat profiles" and "Link a mobile" rows are disabled and "Settings" stays enabled; hidden users absent; `userPickerChatPreferencesRow`: "Chat preferences" opens "Your preferences" (`UserPicker.kt:225-231`; `Preferences.kt:73`); `userPickerLinkAMobileRow`: "Link a mobile" opens the screen titled "Link a mobile" with "This device name" (`UserPicker.kt:368-377`; `C/views/remote/ConnectMobileView.kt:94-95`); `userPickerSettingsRow`: "Settings" opens "Your settings" (`UserPicker.kt:381-393`; `SettingsView.kt:99`) | — | – | -| Profiles | List and add | "Your chat profiles" → "Add profile" → "Create profile" | `C/views/usersettings/UserProfilesView.kt:38,150`; `WelcomeView.kt:61,369` | `profilesListShowsAllVisible`, `profilesAddProfile`, `profilesAddProfileInvalidName`, `profilesAddProfileDuplicateNameError`, `profilesActivateByTap`, `profilesSearchByName` | `profilesListShowsAllVisible`: with users "work" and a hidden "secret" created through the API, the list shows the rows "alice" and "work" but not "secret" (a hidden non-active user is listed only when the search text is its password, `filteredUsers`, `UserProfilesView.kt:305-315`), then "Add profile" and the footer "Tap to activate profile." (:176-188); `profilesAddProfile`: "Add profile" → "Create profile" with a name creates the profile, makes it active and lists it (`apiCreateActiveUser`, `WelcomeView.kt:369-385`); `profilesAddProfileInvalidName`: a name such as "?bob" shows the red info icon and keeps "Create profile" disabled; the icon opens "Invalid name!" / "Correct name to bob?", and confirming puts "bob" in the field (`WelcomeView.kt:120-131,157,480-499`; `mkValidName "?alice"` is `"alice"`, `tests/ValidNames.hs:14`); `profilesAddProfileDuplicateNameError`: a duplicate name shows the "Duplicate display name!" alert ("You already have a chat profile with the same display name. Please choose another name.", `SimpleXAPI.kt:987-991`); `profilesActivateByTap`: clicking another profile's row makes it the active user and the chat list shows its chats (`changeActiveUser`, `UserProfilesView.kt:56-64`); `profilesSearchByName`: typing part of a name lists only the profiles whose name contains it, ignoring case, and hides "Add profile" (`filteredUsers`, `UserProfilesView.kt:305-315`; :181-185) | — | – | -| Profiles | Hidden-profile notice | Open with 2+ users | `UserProfilesView.kt:190-202` | `profilesHiddenNoticeFirstTime` | "Make profile private!" / "Don't show again" | — | – | -| Profiles | Hide and unhide | Right-click a user → "Hide" → "Hide profile": "Password to show", "Confirm password", "Save profile password"; search a password; "Unhide" → "Unhide profile" / "Profile password" / "Unhide chat profile" | `UserProfilesView.kt:99-142,208-255,263,305-328`; `HiddenProfileView.kt:27` | `profilesHideProfileWithPassword`, `profilesHidePasswordMismatchDisabled`, `profilesRevealHiddenByPasswordInSearch`, `profilesActivateHiddenViaSearchPassword`, `profilesUnhideActiveRequiresPassword`, `profilesHideOnlyWhenMoreThanOneVisible` | `profilesHideProfileWithPassword`: "Hide" on the non-active user, both passwords typed and "Save profile password" close the screen; the user's row leaves the list (a hidden non-active user is listed only for its password, `filteredUsers`, `UserProfilesView.kt:305-315`) and the "Enter password in search" banner shows for 10 s (:129-140, 166-171); `profilesHidePasswordMismatchDisabled`: while "Confirm password" differs from "Password to show", "Save profile password" stays disabled (`HiddenProfileView.kt:69-70,79`); `profilesRevealHiddenByPasswordInSearch`: a typed password reveals the hidden user; `profilesActivateHiddenViaSearchPassword`: clicking the revealed row makes it the active user and the chat list shows its chats (`changeActiveUser` with the typed password, `UserProfilesView.kt:56-64,324-325`); `profilesUnhideActiveRequiresPassword`: "Unhide" of the active hidden user asks for "Profile password"; `profilesHideOnlyWhenMoreThanOneVisible`: with one visible user (the other one hidden), the visible user's menu has no "Hide" ("Mute" and "Delete" remain, `UserProfilesView.kt:231-251`). Core side: `testUserPrivacy` (H/Direct.hs:2752) | — | Banner timing is 10 s; a second user is created in the test | +| Picker | Unread per user, muted user | Badge on inactive avatars | `D/views/chatlist/UserPicker.desktop.kt:67-69`; `apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt:96-98`; `UserPicker.kt:457-468,668`; `C/model/ChatModel.kt:1042-1050` | `userPickerUnreadBadgePerUser`, `userPickerMutedUserGrayBadge` | `userPickerUnreadBadgePerUser`: the CLI (contact of both alice and work) sends 2 messages to work and 1 to alice while alice is active: work's avatar shows `2` and the active alice's none; after switching to work, alice's avatar shows `1` and work's none, and the "Your chat profiles" rows show the same counts; `userPickerMutedUserGrayBadge`: grey when muted | P | The badge colour is not observable (no description), so `userPickerMutedUserGrayBadge` is not written yet | +| Picker | Option rows | "Create SimpleX address"/"Your SimpleX address", "Chat preferences", "Your chat profiles", "Link a mobile", "Settings" | `UserPicker.kt:163,211-215,222-330,378-417`; `UserPicker.android.kt:83-87`; `UserPicker.desktop.kt:55-60` | `userPickerDisabledWhenChatStopped` | `userPickerDisabledWhenChatStopped`: "Chat preferences" opens "Your preferences" while chat runs; after `stopChatAsync`, tapping the current user, "Create SimpleX address", "Chat preferences", "Your chat profiles" and "Link a mobile" (Android: "Use from desktop") opens none of their screens (the rows are `disabled = stopped`, `UserPicker.kt:215,237,256,326,392,405`; a tap on them falls through to the picker's scrim and closes it), while "Settings" still opens "Your settings". The label change "Create SimpleX address" → "Your SimpleX address" is checked in §11 `createAddressNoContactsSharesAutomatically`; "Linked mobiles" after a pairing is tested in 04 §K | — | `userPickerAddressRowLabelCreateVsYour` merged into `createAddressNoContactsSharesAutomatically`; `userPickerChatPreferencesRow`, `userPickerLinkAMobileRow` and `userPickerSettingsRow` merged into `userPickerOpensAndShowsCurrentUser`; `userPickerHiddenUsersNotListed` merged into `profilesHideProfileWithPassword` (it checks that the hidden work is absent from the picker) | +| Profiles | List and add | "Your chat profiles" → "Add profile" → "Create profile" | `C/views/usersettings/UserProfilesView.kt:39,58-66,176-191,307-317`; `WelcomeView.kt:64,128-139,166-176,373-390`; `SimpleXAPI.kt:1018-1030` | `profilesListShowsAllVisible`, `profilesAddProfile`, `profilesAddProfileInvalidName` | `profilesListShowsAllVisible`: with users "work" and a hidden "secret" (hidden through the API), the list shows the rows "alice" and "work" but not "secret" (a hidden non-active user is listed only when the search text is its password, `filteredUsers`, `UserProfilesView.kt:307-317`), "Add profile" and the footer "Tap to activate profile." (:177-191); clicking work's row makes it the active user with its group and without alice's (`changeActiveUser`, :58-66); typing "ALI" lists only alice, ignoring case, and hides "Add profile" (:181-188); `profilesAddProfile`: "Add profile" → "Create profile" with a name creates the profile, makes it active and lists it (`apiCreateActiveUser`, `WelcomeView.kt:373-390`); `profilesAddProfileInvalidName`: "?bob" shows the red info icon and "Create profile" does nothing; the icon opens "Invalid name!" / "Correct name to bob?", and confirming puts "bob" in the field (`WelcomeView.kt:128-139,166-176,483-500`; `mkValidName "?alice"` is `"alice"`, `tests/ValidNames.hs:14`); then "alice" (the existing user) shows the "Duplicate display name!" alert ("You already have a chat profile with the same display name. Please choose another name.", `SimpleXAPI.kt:1018-1030`) and no profile is created | — | `profilesSearchByName` and `profilesActivateByTap` merged into `profilesListShowsAllVisible`; `profilesAddProfileDuplicateNameError` merged into `profilesAddProfileInvalidName` | +| Profiles | Hidden-profile notice | Open with 2+ users | `UserProfilesView.kt:191-204` | `profilesHiddenNoticeFirstTime` | With one profile the notice is not shown; with two (the pref turned on after work is created): "Make profile private!" / "You can hide or mute a user profile - hold it for the menu." with "OK" (keeps Pref ShowHiddenProfilesNotice=true, shown again next time) and "Don't show again" (stores false; not shown again) (`UserProfilesView.kt:193-203`) | — | – | +| Profiles | Hide and unhide | Right-click a user → "Hide" → "Hide profile": "Password to show", "Confirm password", "Save profile password"; search a password; "Unhide" → "Unhide profile" / "Profile password" / "Unhide chat profile" | `UserProfilesView.kt:101-114,227-258,264-305,307-332`; `HiddenProfileView.kt:27` | `profilesHideProfileWithPassword`, `profilesHidePasswordMismatchDisabled`, `profilesUnhideActiveRequiresPassword` | `profilesHideProfileWithPassword`: "Hide" on the non-active user, both passwords typed and "Save profile password" close the screen; the user's row leaves the profiles list and the user picker; its name in search lists nothing, its password lists it, and clicking the revealed row makes it the active user with its chats (`filteredUsers`, `changeActiveUser` with the typed password, `UserProfilesView.kt:58-66,307-317,326-327`); the "Enter password in search" banner is `profilesHideShowsPasswordHint` (§15.5 item 5); `profilesHidePasswordMismatchDisabled`: while "Confirm password" differs from "Password to show", "Save profile password" does nothing (`HiddenProfileView.kt:69-70,79`); `profilesUnhideActiveRequiresPassword`: with work hidden and active (hidden through the API), alice is the only visible profile and her menu has "Mute" and "Delete" but no "Hide" (`visibleUsersCount > 1`, :233); work's menu has "Unhide" and "Delete" only; "Unhide" opens "Unhide profile" with "Profile password" and "Unhide chat profile" (`passwordEntryRequired`, :101-114,329-330; `ProfileActionView`, :264-305), a wrong password does nothing, and the right one unhides work and closes the screen. Core side: `testUserPrivacy` (H/Direct.hs:2752) | — | Banner timing is 10 s; a second user is created in the test; `profilesRevealHiddenByPasswordInSearch` and `profilesActivateHiddenViaSearchPassword` merged into `profilesHideProfileWithPassword`; `profilesHideOnlyWhenMoreThanOneVisible` merged into `profilesUnhideActiveRequiresPassword` (the positive branch, "Hide" with two visible profiles, is `profilesHideProfileWithPassword`) | | Profiles | Hidden user not listed or counted | Hide a user that has a contact with the CLI peer | `UserProfilesView.kt:305-315`; `ChatListView.kt:621-625` | `profilesHiddenNotListedAfterHide` | The hidden user is absent from "Your chat profiles"; CLI messages to it don't light the profile button's unread mark, checked once the CLI's `/_get chat @