diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index c16aced912..bcdfbab7d9 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -21,7 +21,7 @@ android { versionCode = (extra["android.version_code"] as String).toInt() versionName = extra["android.version_name"] as String - testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } @@ -93,6 +93,7 @@ android { jniLibs.useLegacyPackaging = rootProject.extra["compression.level"] as Int != 0 } android.sourceSets["main"].assets.setSrcDirs(listOf("../common/src/commonMain/resources/assets")) + android.sourceSets["androidTest"].java.srcDir("../common/src/uiTestShared/kotlin") val isRelease = gradle.startParameter.taskNames.find { it.lowercase().contains("release") } != null val isBundle = gradle.startParameter.taskNames.find { it.lowercase().contains("bundle") } != null // Comma separated list of languages that will be included in the apk @@ -187,8 +188,11 @@ dependencies { testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:runner:1.6.1") + androidTestImplementation("androidx.test:core:1.6.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - //androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version") + androidTestImplementation("androidx.test.espresso:espresso-intents:3.6.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.8.2") debugImplementation("androidx.compose.ui:ui-tooling:1.6.4") } diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt deleted file mode 100644 index f49be92003..0000000000 --- a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package chat.simplex.app - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("chat.simplex.app", appContext.packageName) - } -} \ No newline at end of file diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/SmokeTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/SmokeTest.kt new file mode 100644 index 0000000000..eac29f857b --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/SmokeTest.kt @@ -0,0 +1,12 @@ +package chat.simplex.app.ui + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.common.ui.Ui +import org.junit.Test + +class SmokeTest : UiTest() { + @Test fun bootCreatesUserAndShowsChatList() = uiTest { + app("alice") + waitFor(Ui.ChatList.NewChat, 60_000) + } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/back/BackKeyHelpers.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/back/BackKeyHelpers.kt new file mode 100644 index 0000000000..fa3985f913 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/back/BackKeyHelpers.kt @@ -0,0 +1,50 @@ +package chat.simplex.app.ui.back + +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import chat.simplex.app.MainActivity +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.UiTestApp + +private fun liveMainActivities(): 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 mainActivityFinished(): Boolean = liveMainActivities().all { it.isFinishing } + +internal fun UiScope.backFromRoot() { + Espresso.pressBackUnconditionally() + poll(what = "MainActivity to finish") { mainActivityFinished() } +} + +internal fun UiScope.relaunch(body: () -> Unit) { + poll(what = "the finished MainActivity to be destroyed") { liveMainActivities().isEmpty() } + ActivityScenario.launch(MainActivity::class.java).use { + rule.waitForIdle() + body() + } +} + +internal fun keyboardShown(): Boolean { + var shown = false + UiTestApp.onMain { + val decor = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).firstOrNull()?.window?.decorView + shown = decor != null && ViewCompat.getRootWindowInsets(decor)?.isVisible(WindowInsetsCompat.Type.ime()) == true + } + return shown +} + +internal fun UiScope.hideKeyboard() { + runCatching { poll(timeoutMs = 2_000, what = "the soft keyboard to show", screenshot = false) { keyboardShown() } } + Espresso.closeSoftKeyboard() + poll(what = "the soft keyboard to hide") { !keyboardShown() } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/back/BackKeyTest.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/back/BackKeyTest.kt new file mode 100644 index 0000000000..cbdd7ceecc --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/back/BackKeyTest.kt @@ -0,0 +1,197 @@ +package chat.simplex.app.ui.back + +import chat.simplex.app.ui.harness.ChatScreen +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.whatsNewFromSettings +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.chatController +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.SharedContent +import chat.simplex.common.views.onboarding.OnboardingStage +import org.junit.Test + +class BackKeyTest : UiTest() { + @Test fun back_chatToChatList() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.open("bob") + waitFor(Ui.Chat.Bar.part("title")) + escape() + poll(what = "the chat list") { chatModel.chatId.value == null && !exists(Ui.Chat.Bar.part("title")) } + check(!mainActivityFinished()) { "Back from a chat finished MainActivity" } + } + + @Test fun back_closesChatSearchBeforeChat() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice apple" + bob sends "@alice banana" + val bobId = alice.chatList.chatId("bob") + val chat = alice.chatList.open("bob") + chat.itemId("banana") + chat.openSearch() + typeInto(Ui.Chat.Bar.part("search"), "apple") + poll(what = "search results without `banana`") { chat.itemTexts().let { "apple" in it && "banana" !in it } } + hideKeyboard() + escape() + poll(what = "the search to close and the full chat to return") { + !exists(Ui.Chat.Bar.part("search")) && exists(Ui.Chat.Bar.part("title")) && "banana" in chat.itemTexts() + } + check(chatModel.chatId.value == bobId) { "the first Back left the chat" } + escape() + poll(what = "the chat list") { chatModel.chatId.value == null && !exists(Ui.Chat.Bar.part("title")) } + } + + @Test fun back_exitsSelectionMode() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice one" + bob sends "@alice two" + val bobId = alice.chatList.chatId("bob") + val chat = alice.chatList.open("bob") + val two = chat.itemId("two") + chat.menu("one", "select") + click(Ui.Chat.Item(two)) + poll(what = "two messages selected") { exists(Ui.Chat.Selection.part("title")) && "Selected 2" in textsOf(Ui.Chat.Selection.part("title")) } + escape() + poll(what = "selection mode to end") { !exists(Ui.Chat.Selection.part("title")) && exists(Ui.Chat.Bar.part("title")) } + check(chatModel.chatId.value == bobId) { "Back in selection mode left the chat" } + } + + @Test fun back_closesContactAndGroupInfo() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val bobId = alice.chatList.chatId("bob") + alice.chatList.open("bob").info() + waitFor(Ui.Info.Alias) + escape() + poll(what = "contact info to close to bob's chat") { !ModalManager.end.hasModalsOpen() && !exists(Ui.Info.Alias) && chatModel.chatId.value == bobId } + escape() + poll(what = "the chat list") { chatModel.chatId.value == null && !exists(Ui.Chat.Bar.part("title")) } + alice.newChat().createGroup("team") + val teamId = alice.chatList.chatId("team") + poll(what = "the invite members sheet over team") { chatModel.chatId.value == teamId && ModalManager.end.openModalCount() == 1 } + hideKeyboard() + escape() + poll(what = "the invite members sheet to close") { !ModalManager.end.hasModalsOpen() && exists(Ui.Chat.Bar.part("title")) } + ChatScreen(this).info() + waitFor(Ui.Info.Alias) + escape() + poll(what = "group info to close to team's chat") { !ModalManager.end.hasModalsOpen() && !exists(Ui.Info.Alias) && chatModel.chatId.value == teamId } + } + + @Test fun back_hidesKeyboardFirst() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val bobId = alice.chatList.chatId("bob") + alice.chatList.open("bob") + click(Ui.Chat.Message) + poll(what = "the soft keyboard to show") { keyboardShown() } + escape() + poll(what = "the soft keyboard to hide") { !keyboardShown() } + check(chatModel.chatId.value == bobId && exists(Ui.Chat.Bar.part("title"))) { "the first Back closed the chat" } + } + + @Test fun back_clearsChatListSearch() = uiTest { + app("alice") + typeInto(Ui.ChatList.Search, "zz") + hideKeyboard() + escape() + poll(what = "the search text to clear") { editableText(Ui.ChatList.Search) == "" } + check(!mainActivityFinished()) { "Back with search text finished MainActivity" } + backFromRoot() + } + + @Test fun back_closesUserPicker() = uiTest { + val alice = app("alice") + alice.userPicker() + waitFor(Ui.UserPicker.Sheet) + escape() + poll(what = "the user picker to hide") { !exists(Ui.UserPicker.Sheet) } + check(chatModel.chatId.value == null && !ModalManager.start.hasModalsOpen() && !mainActivityFinished()) { "Back did not return to the chat list" } + } + + @Test fun back_closesNewChatSheet() = uiTest { + val alice = app("alice") + alice.newChat() + waitFor(Ui.NewChat.CreateLink) + escape() + poll(what = "the new chat sheet to close") { !chatModel.newChatSheetVisible.value && !ModalManager.start.hasModalsOpen() && !exists(Ui.NewChat.CreateLink) } + check(!mainActivityFinished()) { "Back on the new chat sheet finished MainActivity" } + } + + @Test fun back_closesModalsOneByOne() = uiTest { + val alice = app("alice") + alice.appearance() + poll(what = "Appearance over Your settings") { ModalManager.start.openModalCount() == 2 && exists(Ui.Appearance.ColorMode) } + escape() + poll(what = "Your settings again") { ModalManager.start.openModalCount() == 1 && exists(Ui.Settings.Appearance) && !exists(Ui.Appearance.ColorMode) } + escape() + poll(what = "the chat list with nothing open") { !ModalManager.start.hasModalsOpen() && !exists(Ui.Settings.Appearance) && !exists(Ui.UserPicker.Sheet) } + check(!mainActivityFinished()) { "Back on Your settings finished MainActivity" } + backFromRoot() + } + + @Test fun back_onChatListRootFinishesActivity() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + val ctrl = chatController.getChatCtrl() + backFromRoot() + relaunch { + alice.chatList.row("bob") + check(chatModel.chatRunning.value == true && chatController.getChatCtrl() == ctrl) { "the core was restarted" } + } + } + + @Test fun back_duringForward_returnsToSourceChat() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hello" + val bobId = alice.chatList.chatId("bob") + alice.chatList.open("bob").menu("hello", "forward") + poll(what = "the forward list") { chatModel.sharedContent.value is SharedContent.Forward && !exists(Ui.Chat.Bar.part("title")) } + escape() + poll(what = "bob's chat again") { chatModel.sharedContent.value == null && chatModel.chatId.value == bobId && exists(Ui.Chat.Bar.part("title")) } + check(!mainActivityFinished()) { "Back during a forward finished MainActivity" } + } + + @Test fun back_onboardingProfile_returnsToWelcome() = uiTest { + app(null) + click(Ui.Onboarding.GetStarted) + waitFor(Ui.Onboarding.ProfileName) + hideKeyboard() + escape() + poll(what = "the welcome screen") { + appPrefs.onboardingStage.get() == OnboardingStage.Step1_SimpleXInfo && exists(Ui.Onboarding.GetStarted) && !exists(Ui.Onboarding.ProfileName) + } + check(chatModel.currentUser.value == null && chatModel.users.isEmpty()) { "a profile was created" } + } + + @Test fun back_onboardingYourNetwork_finishesActivity() = uiTest { + app(null) + click(Ui.Onboarding.GetStarted) + typeInto(Ui.Onboarding.ProfileName, "alice") + hideKeyboard() + poll(what = "Create profile to be enabled") { isEnabled(Ui.Onboarding.CreateProfile) } + click(Ui.Onboarding.CreateProfile) + poll(timeoutMs = 30_000, what = "the Your network step") { appPrefs.onboardingStage.get() == OnboardingStage.Step3_ChooseServerOperators && seesText("Your network") } + backFromRoot() + check(appPrefs.onboardingStage.get() == OnboardingStage.Step3_ChooseServerOperators) { "Back changed the onboarding stage" } + relaunch { + poll(what = "Your network again") { seesText("Your network") } + check(appPrefs.onboardingStage.get() == OnboardingStage.Step3_ChooseServerOperators) { "relaunch changed the onboarding stage" } + } + } + + @Test fun back_closesWhatsNew() = uiTest { + val alice = app("alice") + whatsNewFromSettings(alice) + poll(what = "What's new over Help & support") { ModalManager.start.openModalCount() == 3 && !exists(Ui.Help.WhatsNew) } + escape() + poll(what = "Help & support again") { ModalManager.start.openModalCount() == 2 && exists(Ui.Help.WhatsNew) } + } +} 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 new file mode 100644 index 0000000000..af64bd3671 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/CliPeer.kt @@ -0,0 +1,54 @@ +package chat.simplex.app.ui.harness + +import java.io.BufferedReader +import java.io.BufferedWriter +import java.net.Socket + +object Bridge { + private const val PORT = 7777 + private const val READ_TIMEOUT_MS = 60_000 + private var socket: Socket? = null + private lateinit var input: BufferedReader + private lateinit var output: BufferedWriter + + @Synchronized fun call(cmd: String): List = + try { + exchange(cmd) + } catch (e: java.io.IOException) { + runCatching { socket?.close() } + socket = null + throw e + } + + private fun exchange(cmd: String): List { + if (socket == null) { + val s = Socket("127.0.0.1", PORT).apply { soTimeout = READ_TIMEOUT_MS } + socket = s + input = s.getInputStream().bufferedReader(Charsets.UTF_8) + output = s.getOutputStream().bufferedWriter(Charsets.UTF_8) + } + output.write(cmd); output.write("\n"); output.flush() + val head = input.readLine() ?: throw java.io.EOFException("bridge closed the connection (command: $cmd)") + if (head.startsWith("err ")) throw AssertionError("bridge: ${head.removePrefix("err ")} (command: $cmd)") + if (head.startsWith("lines ")) return List(head.removePrefix("lines ").toInt()) { input.readLine() } + return listOf(head) + } + + fun servers(): Pair = call("servers").first().split(" ").let { it[1] to it[2] } +} + +class CliPeer(name: String, private val flags: PeerFlags, waiter: Waiter) : PeerLines(name, waiter) { + fun start() { + val f = listOfNotNull("reactions".takeIf { flags.reactions }, "markRead".takeIf { flags.markRead }, "fullLinks".takeIf { flags.fullLinks }) + Bridge.call("start $name ${f.joinToString(",")}") + } + + fun stop() { runCatching { Bridge.call("stop $name") } } + + override fun fill() { + val ls = Bridge.call("drain $name") + synchronized(lines) { lines.addAll(ls) } + } + + override fun type(text: String) { Bridge.call("type $name $text") } +} diff --git a/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/PrefStores.kt b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/PrefStores.kt new file mode 100644 index 0000000000..f08b61cbf0 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/PrefStores.kt @@ -0,0 +1,47 @@ +package chat.simplex.app.ui.harness + +import android.content.Context +import android.content.SharedPreferences +import chat.simplex.common.model.AppPreferences +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.platform.getPreferenceFilePath +import java.io.File + +object PrefStores { + private val stores = listOf(AppPreferences.SHARED_PREFS_ID, "chat.simplex.app.THEMES") + private val themesYaml get() = File(getPreferenceFilePath("themes.yaml")) + private var captured: Map> = emptyMap() + private var yamlCapture: ByteArray? = null + + fun capture() { + captured = stores.associateWith { prefs(it).all.toMap() } + yamlCapture = themesYaml.takeIf { it.exists() }?.readBytes() + } + + fun restore() { + for ((name, values) in captured) { + val e = prefs(name).edit().clear() + values.forEach { (k, v) -> e.put(k, v) } + check(e.commit()) { "could not restore preferences $name" } + } + val yaml = yamlCapture + if (yaml == null) themesYaml.delete() else themesYaml.apply { parentFile?.mkdirs() }.writeBytes(yaml) + UiTestApp.onMain { CommonPrefs.reload() } + } + + private fun prefs(name: String): SharedPreferences = androidAppContext.getSharedPreferences(name, Context.MODE_PRIVATE) + + @Suppress("UNCHECKED_CAST") + private fun SharedPreferences.Editor.put(k: String, v: Any?) { + when (v) { + null -> remove(k) + is String -> putString(k, v) + is Int -> putInt(k, v) + is Long -> putLong(k, v) + is Float -> putFloat(k, v) + is Boolean -> putBoolean(k, v) + is Set<*> -> putStringSet(k, v as Set) + else -> error("unsupported preference type ${v.javaClass} for $k") + } + } +} 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 new file mode 100644 index 0000000000..1dfa769a2c --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTest.kt @@ -0,0 +1,354 @@ +package chat.simplex.app.ui.harness + +import android.widget.EditText +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import chat.simplex.app.MainActivity +import chat.simplex.common.model.AppPreferences +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.UiKey +import chat.simplex.common.ui.UiIdle +import android.content.Context +import org.junit.Rule +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers + +abstract class UiTest { + @get:Rule val rule: ComposeTestRule = createEmptyComposeRule() + + fun uiTest(body: UiScope.() -> Unit) { + val watchdog = Watchdog.start(HARD_TIMEOUT_MS) + try { + UiTestApp.bootOnce() + val scope = UiScope(rule) + try { + scope.body() + } finally { + scope.finish() + } + } finally { + watchdog.cancel() + } + } + + private companion object { + const val HARD_TIMEOUT_MS = 300_000L + } +} + +class Watchdog private constructor(private val thread: Thread) { + fun cancel() = thread.interrupt() + + companion object { + fun start(timeoutMs: Long): Watchdog = Watchdog(Thread { + try { + Thread.sleep(timeoutMs) + } catch (e: InterruptedException) { + return@Thread + } + val dump = Thread.getAllStackTraces().entries.joinToString("\n\n") { (t, st) -> "\"${t.name}\" ${t.state}\n" + st.joinToString("\n") { " at $it" } } + dump.lines().forEach { android.util.Log.e("UITEST", it) } + android.util.Log.e("UITEST", "hard timeout after $timeoutMs ms; killing the process so the runner can resume") + android.os.Process.killProcess(android.os.Process.myPid()) + }.apply { isDaemon = true; name = "uitest-watchdog"; start() }) + } +} + +class UiScope(val rule: ComposeTestRule) { + private var scenario: ActivityScenario? = null + private val peers = mutableListOf() + private var intentsActive = false + + fun app(user: String?): App { + Timing.phase("reset") { UiTestApp.resetForTest(user) } + Timing.phase("launch") { launch() } + return App(this) + } + + fun restart(): App { + UiTestApp.restartCore() + poll(timeoutMs = 60_000, what = "the core to start again with chat running") { + 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() + } + Timing.phase("close") { close() } + Timing.phase("launch") { launch() } + return App(this) + } + + fun assumeCamera() { + val cameras = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext + .getSystemService(android.hardware.camera2.CameraManager::class.java).cameraIdList + org.junit.Assume.assumeTrue("the device has no camera for the QR scanner", cameras.isNotEmpty()) + } + + fun finish() { + peers.forEach { it.stop() } + Timing.phase("close") { close() } + Timing.phase("drain") { UiTestApp.drain() } + } + + private fun launch() { + Intents.init() + intentsActive = true + Intents.intending(org.hamcrest.CoreMatchers.not(IntentMatchers.isInternal())).respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)) + scenario = ActivityScenario.launch(MainActivity::class.java) + rule.waitForIdle() + } + + private fun close() { + if (intentsActive) Intents.release() + intentsActive = false + scenario?.close() + scenario = null + } + + fun cli(name: String, flags: PeerFlags = PeerFlags()): CliPeer = + CliPeer(name, flags) { t, what, cond -> poll(t, what, cond = cond) }.also { peers += it; Timing.phase("peer $name") { it.start() } } + + fun poll(timeoutMs: Long = 10_000, what: String, screenshot: Boolean = true, cond: () -> Boolean) { + val start = System.currentTimeMillis() + val deadline = start + timeoutMs + var busyAtDeadline: Set? = null + var lastError: Throwable? = null + while (true) { + rule.mainClock.advanceTimeBy(48, ignoreFrameDuration = true) + rule.waitForIdle() + if (runCatching(cond).onFailure { lastError = it }.getOrDefault(false)) { + Timing.wait(what, System.currentTimeMillis() - start) + return + } + 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 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)}") + } + Thread.sleep(50) + } + } + + fun matcher(key: UiKey): SemanticsMatcher = + if (key.id == DESCRIBED) hasContentDescription(key.arg as String) and hasClickAction() else hasTestTag(key.tag) + fun exists(key: UiKey): Boolean = rule.onAllNodes(matcher(key)).fetchSemanticsNodes().isNotEmpty() + fun waitFor(key: UiKey, timeoutMs: Long = 10_000) = poll(timeoutMs, "node ${key.tag}") { exists(key) } + + private fun waitPopupsClosedUnlessInside(key: UiKey) { + if (rule.onAllNodes(matcher(key) and hasAnyAncestor(isPopup())).fetchSemanticsNodes().isNotEmpty()) return + poll(what = "open popups to close before acting on ${key.tag}") { rule.onAllNodes(isPopup()).fetchSemanticsNodes().isEmpty() } + } + + fun settle() { + rule.mainClock.advanceTimeBy(SETTLE_MS, ignoreFrameDuration = true) + rule.waitForIdle() + } + + + fun scrollTo(key: UiKey) { + poll(what = "a list to scroll to ${key.tag}") { + exists(key) || rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollToIndex)).let { lists -> + lists.fetchSemanticsNodes().indices.any { i -> runCatching { lists[i].performScrollToNode(matcher(key)) }.isSuccess && exists(key) } + } + } + } + + fun waitStable(key: UiKey) { + var last: androidx.compose.ui.geometry.Rect? = null + try { + poll(what = "node ${key.tag} to stop moving") { + val b = rule.onAllNodes(matcher(key)).fetchSemanticsNodes().firstOrNull()?.boundsInRoot + (b != null && !b.isEmpty && b == last).also { last = b } + } + } catch (e: AssertionError) { + throw AssertionError("${e.message}; last bounds $last", e) + } + } + + fun click(key: UiKey) { + waitFor(key) + runCatching { rule.onNode(matcher(key)).performScrollTo() } + centerInScrollParent(key) + waitStable(key) + poll(what = "node ${key.tag} to be enabled") { isEnabled(key) } + waitPopupsClosedUnlessInside(key) + val node = rule.onNode(matcher(key), useUnmergedTree = key.id != DESCRIBED).fetchSemanticsNode() + val point = tapPointInNode(node) + rule.onNode(matcher(key)).performTouchInput { click(point) } + rule.waitForIdle() + } + + private fun centerInScrollParent(key: UiKey) { + val node = rule.onAllNodes(matcher(key)).fetchSemanticsNodes().firstOrNull() ?: return + var parent = node.parent + while (parent != null && !parent.config.contains(SemanticsActions.ScrollBy)) parent = parent.parent + val scroller = parent ?: return + val dy = node.boundsInRoot.center.y - scroller.boundsInRoot.center.y + if (kotlin.math.abs(dy) < 1f) return + rule.runOnUiThread { scroller.config[SemanticsActions.ScrollBy].action?.invoke(0f, dy) } + rule.waitForIdle() + } + + fun rightClick(key: UiKey) { + waitFor(key) + waitStable(key) + waitPopupsClosedUnlessInside(key) + rule.onNode(matcher(key)).performTouchInput { longClick() } + rule.waitForIdle() + } + + fun escape() { + Espresso.pressBack() + rule.waitForIdle() + } + + private fun isMessageField(key: UiKey) = key.tag == Ui.Chat.Message.tag + + private fun setMessageField(key: UiKey, value: String) { + waitFor(key) + val deadline = System.currentTimeMillis() + 10_000 + while (true) { + Espresso.onView(ViewMatchers.isAssignableFrom(EditText::class.java)).perform(ViewActions.replaceText(value)) + try { + poll(timeoutMs = 2_000, what = "message field to hold `$value`", screenshot = false) { editableText(key) == value } + return + } catch (e: AssertionError) { + if (System.currentTimeMillis() > deadline) throw AssertionError("${e.message}; field holds `${editableText(key)}`", e) + } + } + } + + fun enterText(key: UiKey, text: String) { + waitFor(key) + rule.onNode(matcher(key)).performTextInput(text) + rule.waitForIdle() + } + + fun typeInto(key: UiKey, text: String) { + if (isMessageField(key)) { + waitFor(key) + setMessageField(key, editableText(key).orEmpty() + text) + return + } + waitFor(key) + val before = editableText(key).orEmpty() + rule.onNode(matcher(key)).performTextInput(text) + poll(what = "field ${key.tag} to hold `$text`") { editableText(key) == before + text } + } + + fun replaceText(key: UiKey, text: String) { + if (isMessageField(key)) { + setMessageField(key, text) + return + } + waitFor(key) + rule.onNode(matcher(key)).performTextReplacement(text) + poll(what = "field ${key.tag} to hold `$text`") { editableText(key) == text } + } + + fun pressEnter(key: UiKey) { + if (isMessageField(key)) click(Ui.Chat.Send) else rule.onNode(matcher(key)).performImeAction() + rule.waitForIdle() + } + + fun isEnabled(key: UiKey): Boolean = + rule.onAllNodes(matcher(key)).fetchSemanticsNodes().firstOrNull()?.let { !it.config.contains(SemanticsProperties.Disabled) } ?: false + + 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() } + 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 textsOf(key: UiKey): List { + waitFor(key) + val own = rule.onNode(matcher(key)).fetchSemanticsNode().config.getOrElseNullable(SemanticsProperties.Text) { null }.orEmpty().map { it.text } + val inner = rule.onAllNodes(hasAnyAncestor(matcher(key)) and SemanticsMatcher.keyIsDefined(SemanticsProperties.Text), useUnmergedTree = true) + .fetchSemanticsNodes().flatMap { it.config[SemanticsProperties.Text].map { t -> t.text } } + return own + inner + } + + fun textOf(key: UiKey): String? = textsOf(key).firstOrNull() + + fun descriptionsOf(key: UiKey): List { + waitFor(key) + return rule.onAllNodes((matcher(key) or hasAnyAncestor(matcher(key))) and SemanticsMatcher.keyIsDefined(SemanticsProperties.ContentDescription), useUnmergedTree = true) + .fetchSemanticsNodes().flatMap { it.config[SemanticsProperties.ContentDescription] } + } + + fun texts(): List = + rule.onAllNodes(hasText("", substring = true), useUnmergedTree = true).fetchSemanticsNodes() + .flatMap { it.config.getOrElseNullable(SemanticsProperties.Text) { null }.orEmpty() }.map { it.text } + + fun seesDescription(text: String, substring: Boolean = false) = + rule.onAllNodes(hasContentDescription(text, substring = substring), useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + + fun seesText(text: String, substring: Boolean = false) = + rule.onAllNodes(hasText(text, substring = substring), useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + + fun storedSetting(key: String): String? = + androidAppContext.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE).all[key]?.toString() + + fun sliderValue(key: UiKey): Float { + waitFor(key) + return rule.onNode(matcher(key)).fetchSemanticsNode().config[SemanticsProperties.ProgressBarRangeInfo].current + } + + fun setSlider(key: UiKey, value: Float) { + waitFor(key) + runCatching { rule.onNode(matcher(key)).performScrollTo() } + centerInScrollParent(key) + waitStable(key) + rule.onNode(matcher(key)).performSemanticsAction(SemanticsActions.SetProgress) { it(value) } + rule.waitForIdle() + } + + fun clipboardText(): String? = + Intents.getIntents().asReversed().firstNotNullOfOrNull { i -> + 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() +} + +object FailureShots { + private var n = 0 + + fun take(what: String): String? = runCatching { + val inst = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + val dir = java.io.File(androidAppContext.getExternalFilesDir(null) ?: error("no external files dir"), "failures").apply { mkdirs() } + val name = "%02d-%s.png".format(++n, what.replace(Regex("[^A-Za-z0-9]+"), "_").take(60)) + val bmp = inst.uiAutomation.takeScreenshot() ?: error("UiAutomation.takeScreenshot returned null") + java.io.File(dir, name).outputStream().use { bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) } + name + }.onFailure { android.util.Log.e("UITEST", "failure screenshot: $it") }.getOrNull() +} + +object Timing { + private const val TAG = "UITEST" + + fun phase(name: String, body: () -> T): T { + val start = System.currentTimeMillis() + return body().also { android.util.Log.i(TAG, "phase ${System.currentTimeMillis() - start} ms: $name") } + } + + fun wait(what: String, ms: Long) { + if (ms >= 500) android.util.Log.i(TAG, "wait $ms ms: $what") + } +} + +private const val SETTLE_MS = 500L +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 new file mode 100644 index 0000000000..c93b489d45 --- /dev/null +++ b/apps/multiplatform/android/src/androidTest/java/chat/simplex/app/ui/harness/UiTestApp.kt @@ -0,0 +1,153 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.views.helpers.DBMigrationResult +import chat.simplex.common.model.ChatError +import chat.simplex.common.model.Profile +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.platform.initChatControllerOnStart +import chat.simplex.common.views.database.deleteChatDatabaseFilesAndState +import chat.simplex.common.views.onboarding.OnboardingStage +import chat.simplex.common.views.onboarding.setLastVersionDefault +import chat.simplex.common.platform.agentDatabaseFileName +import chat.simplex.common.platform.chatDatabaseFileName +import chat.simplex.common.platform.dataDir +import chat.simplex.common.views.helpers.DatabaseUtils +import java.io.File +import kotlinx.coroutines.runBlocking + +object UiTestApp { + private var booted = false + + fun bootOnce() { + if (booted) return + booted = true + waitUntil(STARTUP_MS, "the app's own startup init") { chatModel.chatDbStatus.value != null && !chatModel.ctrlInitInProgress.value } + check(chatModel.chatDbStatus.value == DBMigrationResult.OK) { "startup database: ${chatModel.chatDbStatus.value}" } + Timing.phase("boot.saveTemplate") { + if (runBlocking { chatController.listUsers(null) }.isNotEmpty()) { + stopCore() + deleteChatDatabaseFilesAndState() + runBlocking { initChatController() } + check(chatModel.chatDbStatus.value == DBMigrationResult.OK) { "fresh database: ${chatModel.chatDbStatus.value}" } + } + val key = DatabaseUtils.ksDatabasePassword.get() ?: "" + val decryptError = if (key.isEmpty()) null else runBlocking { + chatController.apiStopChat() + chatController.apiStorageEncryption(currentKey = key, newKey = "") + } + if (decryptError != null) android.util.Log.e("UITEST", "template stays encrypted, removing encryption failed: ${chat.simplex.common.model.json.encodeToString(ChatError.serializer(), decryptError)}") + stopCore() + if (decryptError == null) DatabaseUtils.ksDatabasePassword.remove() + DbTemplate.save() + } + presetAndroidPrefs() + PrefStores.capture() + ModelSnapshot.capture() + } + + fun drain() { + val deadline = System.currentTimeMillis() + DRAIN_MS + while (chat.simplex.common.ui.UiIdle.inFlight().isNotEmpty() && System.currentTimeMillis() < deadline) Thread.sleep(20) + val stuck = chat.simplex.common.ui.UiIdle.inFlight() + if (stuck.isNotEmpty()) { + android.util.Log.w("UITEST", "drain gave up after ${DRAIN_MS / 1000} s; abandoned: ${stuck.joinToString { it.what }}") + chat.simplex.common.ui.UiIdle.abandon(stuck) + } + } + + fun onMain(action: () -> Unit) = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().runOnMainSync(action) + + private fun presetAndroidPrefs() { + appPrefs.appearanceBarsBlurRadius.set(0) + appPrefs.notificationsMode.set(chat.simplex.common.model.NotificationsMode.OFF) + appPrefs.backgroundServiceNoticeShown.set(true) + appPrefs.backgroundServiceBatteryNoticeShown.set(true) + appPrefs.canAskToEnableNotifications.set(false) + appPrefs.privacyProtectScreen.set(false) + } + + fun resetForTest(user: String?) { + Timing.phase("reset.stop running=${chatModel.chatRunning.value}") { stopCore() } + Timing.phase("reset.delete") { deleteChatDatabaseFilesAndState() } + Timing.phase("reset.prefs") { PrefStores.restore() } + Timing.phase("reset.ui") { CommonUiState.clear(::onMain) } + if (user != null) TestPrefs.preset() + DbTemplate.install() + Timing.phase("reset.init") { runBlocking { initChatController() } } + check(chatModel.chatDbStatus.value == DBMigrationResult.OK) { "database: ${chatModel.chatDbStatus.value}" } + if (user == null) return + val u = Timing.phase("reset.createUser") { runBlocking { chatController.apiCreateActiveUser(null, Profile(user, "", null)) } } ?: error("apiCreateActiveUser($user) failed") + chatModel.currentUser.value = u + chatModel.localUserCreated.value = true + appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) + Timing.phase("reset.startChat") { runBlocking { chatController.startChat(u) } } + val (smp, xftp) = Bridge.servers() + Timing.phase("reset.servers") { configureServers(smp, xftp) } + setLastVersionDefault(chatModel) + CommonUiState.clearTransient(::onMain) + } + + fun restartCore() { + runBlocking { + val ctrl = chatController.getChatCtrl() + if (ctrl != null && ctrl != -1L && chatModel.chatRunning.value != false) chatController.apiStopChat() + } + chatModel.chatRunning.value = null + val ctrl = chatController.getChatCtrl() + if (ctrl != null && ctrl != -1L) chatCloseStore(ctrl).let { check(it.isEmpty()) { "chatCloseStore failed: $it" } } + chatController.setChatCtrl(null) + CommonUiState.clear(::onMain) + initChatControllerOnStart() + } + + private fun stopCore() { + waitUntil(10_000, "ctrlInitInProgress to clear") { !chatModel.ctrlInitInProgress.value } + runBlocking { + val ctrl = chatController.getChatCtrl() + if (ctrl != null && ctrl != -1L && chatModel.chatRunning.value == true) chatController.apiStopChat() + } + chatModel.chatRunning.value = null + val ctrl = chatController.getChatCtrl() + if (ctrl != null && ctrl != -1L) chatCloseStore(ctrl).let { check(it.isEmpty()) { "chatCloseStore failed: $it" } } + chatController.setChatCtrl(null) + } + + private fun waitUntil(timeoutMs: Long, what: String, cond: () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (!cond()) { + if (System.currentTimeMillis() > deadline) throw AssertionError("timed out after $timeoutMs ms waiting for $what") + Thread.sleep(50) + } + } + + private const val STARTUP_MS = 180_000L + private const val DRAIN_MS = 30_000L +} + +object DbTemplate { + private val dir get() = File(dataDir, "uitest-db-template") + private val keyFile get() = File(dir, "key") + + fun install() { + check(keyFile.isFile) { "no database template; bootOnce saves it" } + dbFiles(dir).forEach { it.copyTo(File(dataDir, it.name), overwrite = true) } + val key = keyFile.readText() + if (key.isEmpty()) DatabaseUtils.ksDatabasePassword.remove() else DatabaseUtils.ksDatabasePassword.set(key) + appPrefs.storeDBPassphrase.set(true) + appPrefs.initialRandomDBPassphrase.set(true) + } + + fun save() { + dir.deleteRecursively() + dir.mkdirs() + dbFiles(dataDir).forEach { it.copyTo(File(dir, it.name)) } + keyFile.writeText(DatabaseUtils.ksDatabasePassword.get() ?: "") + } + + private fun dbFiles(from: File): List = + from.listFiles { f -> f.isFile && (f.name.startsWith(chatDatabaseFileName) || f.name.startsWith(agentDatabaseFileName)) }.orEmpty().toList() +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 1a4c836b2e..40b8d56f8b 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -3,6 +3,7 @@ package chat.simplex.app import android.annotation.SuppressLint import android.app.* import android.content.Context +import chat.simplex.common.ui.initUiTags import chat.simplex.common.platform.Log import android.content.Intent import android.content.pm.ActivityInfo @@ -189,6 +190,7 @@ class SimplexApp: Application(), LifecycleEventObserver { private fun initMultiplatform() { androidAppContext = this + initUiTags(this) APPLICATION_ID = BuildConfig.APPLICATION_ID ntfManager = object : chat.simplex.common.platform.NtfManager() { override fun notifyCallInvitation(invitation: RcvCallInvitation): Boolean = NtfManager.notifyCallInvitation(invitation) diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index e9503bc6ce..13d9fb3b39 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -1,3 +1,4 @@ +import java.time.Duration plugins { kotlin("multiplatform") id("org.jetbrains.compose") @@ -146,7 +147,17 @@ kotlin { implementation("com.squareup.okhttp3:okhttp:4.12.0") } } - val desktopTest by getting + val desktopTest by getting { + kotlin.srcDir("src/uiTestShared/kotlin") + languageSettings.optIn("androidx.compose.ui.test.ExperimentalTestApi") + languageSettings.optIn("androidx.compose.ui.test.InternalTestApi") + languageSettings.optIn("androidx.compose.ui.InternalComposeUiApi") + dependencies { + implementation(compose.desktop.uiTestJUnit4) + implementation(compose.desktop.currentOs) + implementation("org.jetbrains.pty4j:pty4j:0.13.13") + } + } } } @@ -347,3 +358,54 @@ afterEvaluate { } } } + +tasks.named("desktopTest") { exclude("chat/simplex/app/ui/**") } + +tasks.register("desktopUiTest") { + targetName = "desktop" + onlyIf { System.getenv("SIMPLEX_UITEST_RUNNER") == "1" } + val dt = tasks.named("desktopTest").get() + testClassesDirs = dt.testClassesDirs; classpath = dt.classpath + include("chat/simplex/app/ui/**") + forkEvery = (findProperty("uiForkEvery") as String?)?.toLong() ?: 0 + maxParallelForks = (findProperty("uiForks") as String?)?.toInt() ?: 1 + maxHeapSize = "2g" + timeout.set(Duration.ofHours(12)) + filter { isFailOnNoMatchingTests = findProperty("uiAllowEmpty") == null } + val runDir = findProperty("uiRunDir") as String? + environment.remove("XDG_DATA_HOME"); environment.remove("XDG_CONFIG_HOME") + environment("GSETTINGS_BACKEND", "memory") + if (runDir != null) environment("XDG_CACHE_HOME", "$runDir/cache") + environment.remove("FREETYPE_PROPERTIES") + jvmArgs("-Duser.language=en", "-Duser.country=US", "-Duser.timezone=UTC") + if (runDir != null) jvmArgs("-Djava.io.tmpdir=$runDir/jvmtmp", "-XX:+HeapDumpOnOutOfMemoryError", "-XX:HeapDumpPath=$runDir", "-XX:ErrorFile=$runDir/hs_err_pid%p.log") + systemProperty("java.awt.headless", "true") + systemProperty("skiko.data.path", rootProject.file("build/uitest/skiko").absolutePath) + systemProperty("simplex.uitest.env", rootProject.file("build/uitest/env.properties").absolutePath) + systemProperty("kotlinx.coroutines.io.parallelism", "256") + testLogging { events("passed", "failed", "skipped"); showStandardStreams = true; exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL } + outputs.upToDateWhen { false } + val pass = (findProperty("uiPass") as String?) ?: "1" + reports.junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/desktopUiTest/pass$pass")) + reports.html.outputLocation.set(layout.buildDirectory.dir("reports/tests/desktopUiTest/pass$pass")) + binaryResultsDirectory.set(layout.buildDirectory.dir("test-results/desktopUiTest/pass$pass/binary")) +} + +tasks.register("uitestPrepare") { + val runtime = configurations.getByName("desktopTestRuntimeClasspath") + doLast { runtime.resolve() } +} + +tasks.register("uitestBridgeClasspath") { + val dt = tasks.named("desktopTest").get() + dependsOn("desktopTestClasses") + doLast { + val snapshot = rootProject.file("build/uitest/bridge-classes") + snapshot.deleteRecursively() + val testDirs = dt.testClassesDirs.files + testDirs.forEach { d -> project.copy { from(d); include("chat/simplex/app/ui/harness/**"); into(snapshot) } } + val cp = listOf(snapshot) + dt.classpath.files.filter { it !in testDirs } + rootProject.file("build/uitest/bridge.classpath").writeText(cp.joinToString(File.pathSeparator)) + } +} + 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 71d7ff3cb4..96a6a1e14d 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,6 +36,8 @@ 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.ui import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -96,7 +98,7 @@ actual fun PlatformTextField( } val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl - AndroidView(modifier = Modifier, factory = { context -> + AndroidView(modifier = Modifier.ui(Ui.Chat.Message), factory = { context -> val editText = @SuppressLint("AppCompatCustomView") object: EditText(context) { override fun setOnReceiveContentListener( mimeTypes: Array?, diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/UiTags.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/UiTags.android.kt new file mode 100644 index 0000000000..fc698b9869 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/ui/UiTags.android.kt @@ -0,0 +1,8 @@ +package chat.simplex.common.ui + +import android.content.Context +import android.content.pm.ApplicationInfo + +fun initUiTags(context: Context) { + uiTestHooksEnabled = context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt index 9a3d9e5e4f..84249b3e7f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import chat.simplex.common.platform.onRightClick +import chat.simplex.common.ui.UiKey +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* @Composable @@ -19,8 +21,9 @@ actual fun ChatListNavLinkLayout( disabled: Boolean, selectedChat: State, nextChatSelected: State, + uiKey: UiKey?, ) { - var modifier = Modifier.fillMaxWidth() + var modifier = Modifier.fillMaxWidth().ui(uiKey) if (!disabled) modifier = modifier .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt index 9649e37c0f..702fef3e99 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.android.kt @@ -22,6 +22,8 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.User import chat.simplex.common.model.UserInfo 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.helpers.* import kotlinx.coroutines.CancellationException @@ -78,6 +80,7 @@ fun UserPickerUserBox( Row( modifier = modifier .userPickerBoxModifier() + .ui(Ui.UserPicker.User(userInfo.user.userId)) .clickable ( onClick = { onClick(userInfo.user) }, enabled = !stopped @@ -144,6 +147,7 @@ actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow< Box( Modifier .fillMaxSize() + .ui(if (pickerIsVisible) Ui.UserPicker.Sheet else null) .then(clickableModifier) .drawBehind { val pos = calculatePosition(dismissState) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt index 0e1cf9472a..eda66f24f7 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.android.kt @@ -5,12 +5,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import chat.simplex.common.model.SharedPreference import chat.simplex.common.model.User +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.res.* @Composable actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)?) { if (user == null) { - OnboardingActionButton(Modifier.fillMaxWidth(), labelId = MR.strings.get_started, onboarding = OnboardingStage.Step2_CreateProfile, onclick = onclick) + OnboardingActionButton(Modifier.fillMaxWidth().ui(Ui.Onboarding.GetStarted), labelId = MR.strings.get_started, onboarding = OnboardingStage.Step2_CreateProfile, onclick = onclick) } else { OnboardingActionButton(Modifier.fillMaxWidth(), labelId = MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onclick = onclick) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 1afea4541b..ed007e11a7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -839,7 +839,7 @@ object ChatModel { private val chatsToPop = mutableMapOf() init { - withLongRunningApi { + withLongRunningApi(idle = false) { subject .throttleLatest(2000) .collect { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index faf27d3ceb..90ed85b6fb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3,6 +3,7 @@ package chat.simplex.common.model import SectionItemView import androidx.compose.foundation.layout.* import androidx.compose.material.* +import chat.simplex.common.ui.UiIdle import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.Saver @@ -807,10 +808,10 @@ object ChatController { val msg = recvMsg(ctrl) releaseLock = getWakeLock(timeout = 60000) if (msg != null) { - val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { + val finishedWithoutTimeout = UiIdle.trackEvent({ "event ${msg.responseType}" }) { withTimeoutOrNull(60_000L) { processReceivedMsg(msg) messagesChannel.trySend(msg) - } + } } if (finishedWithoutTimeout == null) { Log.e(TAG, "Timeout reached while processing received message: " + msg.responseType) if (appPreferences.developerTools.get() && appPreferences.showSlowApiCalls.get()) { @@ -938,7 +939,7 @@ object ChatController { suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, retryNum: Int = 0, log: Boolean = true): API { val ctrl = otherCtrl ?: chatCtrl ?: throw Exception("Controller is not initialized") - return withContext(Dispatchers.IO) { + return UiIdle.track(if (cmd is CC.GetAgentSubsTotal || cmd is CC.GetAgentServersSummary) null else { { "cmd ${cmd.cmdType}" } }) { withContext(Dispatchers.IO) { val c = cmd.cmdString if (log) { chatModel.addTerminalItem(TerminalItem.cmd(rhId, cmd.obfuscated)) @@ -956,7 +957,7 @@ object ChatController { chatModel.addTerminalItem(TerminalItem.resp(rhId, r)) } r - } + } } } // Spec: spec/api.md#recvMsg 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 new file mode 100644 index 0000000000..27eab85491 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/Ui.kt @@ -0,0 +1,176 @@ +package chat.simplex.common.ui + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag + +@Immutable +data class UiKey(val id: String, val arg: Any? = null) { + val tag: String get() = if (arg == null) id else "$id[$arg]" + fun option(value: Any) = UiKey("$id.option", value) + fun part(name: String) = UiKey("$id.$name", arg) +} + +@PublishedApi +@Volatile +internal var uiTestHooksEnabled = false + +fun Modifier.ui(key: UiKey?): Modifier = if (key == null || !uiTestHooksEnabled) this else testTag(key.tag) + +object Ui { + object ChatList { + val NewChat = UiKey("chatList.newChat") + val NewChatFab = UiKey("chatList.newChatFab") + val UserPicker = UiKey("chatList.userPicker") + val Search = UiKey("chatList.search") + val Bar = UiKey("chatList.bar") + val Stopped = UiKey("chatList.stopped") + val Subscriptions = UiKey("chatList.subscriptions") + val UnreadFilter = UiKey("chatList.unreadFilter") + val NoUnreadChats = UiKey("chatList.noUnreadChats") + val ConnectByName = UiKey("chatList.connectByName") + val OneHandCard = UiKey("chatList.oneHandCard") + val ConnectBanner = UiKey("chatList.connectBanner") + val PresetMenu = UiKey("chatList.presetMenu") + fun PresetTag(kind: String) = UiKey("chatList.presetTag", kind) + fun PresetMenuItem(kind: String) = UiKey("chatList.presetMenu.item", kind) + val PresetMenuAll = UiKey("chatList.presetMenu.all") + val AddList = UiKey("chatList.addList") + fun UserTag(tagId: Long) = UiKey("chatList.userTag", tagId) + fun UserTagMenu(tagId: Long, action: String) = UiKey("chatList.userTag.menu.$action", tagId) + fun Row(chatId: String, remoteHostId: Long? = null) = UiKey("chatList.row", rowArg(chatId, remoteHostId)) + fun RowMenu(chatId: String, action: String, remoteHostId: Long? = null) = UiKey("chatList.row.menu.$action", rowArg(chatId, remoteHostId)) + private fun rowArg(chatId: String, rh: Long?) = if (rh == null) chatId else "$rh:$chatId" + } + object TagList { + fun Tag(tagId: Long) = UiKey("tagList.tag", tagId) + } + object TagEditor { + val Emoji = UiKey("tagEditor.emoji") + val Name = UiKey("tagEditor.name") + val Save = UiKey("tagEditor.save") + } + object NewChat { + val CreateLink = UiKey("newChat.createLink") + val CreateGroup = UiKey("newChat.createGroup") + } + object UserPicker { + fun User(userId: Long) = UiKey("userPicker.user", userId) + val Address = UiKey("userPicker.address") + val Settings = UiKey("userPicker.settings") + val Profiles = UiKey("userPicker.profiles") + val Sheet = UiKey("userPicker.sheet") + } + object Settings { + val Appearance = UiKey("settings.appearance") + val Help = UiKey("settings.help") + } + object Help { + val WhatsNew = UiKey("help.whatsNew") + } + object WhatsNew { + val Ok = UiKey("whatsNew.ok") + val ViewConditions = UiKey("whatsNew.viewConditions") + } + object ServersSummary { + val ShowPercentage = UiKey("serversSummary.showPercentage") + } + object Onboarding { + val GetStarted = UiKey("onboarding.getStarted") + val ProfileName = UiKey("onboarding.profileName") + val CreateProfile = UiKey("onboarding.createProfile") + val LinkAMobile = UiKey("onboarding.linkAMobile") + val WhyBuilt = UiKey("onboarding.whyBuilt") + val Migrate = UiKey("onboarding.migrate") + val NewPassphrase = UiKey("onboarding.newPassphrase") + val ConfirmPassphrase = UiKey("onboarding.confirmPassphrase") + val SetPassphrase = UiKey("onboarding.setPassphrase") + val RandomPassphrase = UiKey("onboarding.randomPassphrase") + val SetupRouters = UiKey("onboarding.setupRouters") + val SetupNotifications = UiKey("onboarding.setupNotifications") + val Continue = UiKey("onboarding.continue") + fun Operator(operatorId: Long) = UiKey("onboarding.operator", operatorId) + val OperatorsOk = UiKey("onboarding.operatorsOk") + val HowItHelps = UiKey("onboarding.howItHelps") + val ConditionsLink = UiKey("onboarding.conditionsLink") + val Accept = UiKey("onboarding.accept") + val ConditionsAccept = UiKey("onboarding.conditionsAccept") + } + object Remote { + val DeviceName = UiKey("remote.deviceName") + val ShareLink = UiKey("remote.shareLink") + } + object Appearance { + val ColorMode = UiKey("appearance.colorMode") + val FontScale = UiKey("appearance.fontScale") + val Zoom = UiKey("appearance.zoom") + } + object Address { + val Create = UiKey("address.create") + val Share = UiKey("address.share") + } + object NewGroup { + val Name = UiKey("newGroup.name") + val Create = UiKey("newGroup.create") + } + object Profile { + val Name = UiKey("profile.name") + val Save = UiKey("profile.save") + } + object Profiles { + val Add = UiKey("profiles.add") + } + object NewProfile { + val Name = UiKey("newProfile.name") + val Create = UiKey("newProfile.create") + } + object Link { + val Text = UiKey("link.text") + val ShareProfile = UiKey("link.shareProfile") + val ProfileIncognito = UiKey("link.profile.incognito") + } + object Chat { + val Bar = UiKey("chat.bar") + val Message = UiKey("chat.compose") + val Send = UiKey("chat.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 RequestAccept = UiKey("chat.request.accept") + val RequestReject = UiKey("chat.request.reject") + fun Item(itemId: Long) = UiKey("chat.item", itemId) + fun ItemMenu(itemId: Long, action: String) = UiKey("chat.item.menu.$action", itemId) + 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") + } + object Share { + fun Row(chatId: String) = UiKey("share.row", chatId) + } + object Info { + val Alias = UiKey("info.alias") + val DeleteContact = UiKey("info.deleteContact") + val VerifyCode = UiKey("info.verifyCode") + } + object VerifyCode { + val Code = UiKey("verifyCode.code") + val Mark = UiKey("verifyCode.mark") + } + object InviteMembers { + fun Contact(contactId: Long) = UiKey("inviteMembers.contact", contactId) + val Invite = UiKey("inviteMembers.invite") + } + object GroupInfo { + fun Member(groupMemberId: Long) = UiKey("groupInfo.member", groupMemberId) + } + object MemberInfo { + val Role = UiKey("memberInfo.role") + val Remove = UiKey("memberInfo.remove") + } + enum class AlertSource { Shared, PrivacySensitive } + object Alert { + fun Confirm(src: AlertSource) = UiKey("alert.confirm", src) + fun Dismiss(src: AlertSource) = UiKey("alert.dismiss", src) + fun Option(src: AlertSource, name: String) = UiKey("alert.option.$name", src) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/UiIdle.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/UiIdle.kt new file mode 100644 index 0000000000..efc8532d42 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/UiIdle.kt @@ -0,0 +1,53 @@ +package chat.simplex.common.ui + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +object UiIdle { + class Item(val id: Long, val what: String) + + private val items = ConcurrentHashMap() + private val abandoned = ConcurrentHashMap.newKeySet() + private val nextId = AtomicLong() + @Volatile var lastEventAt = 0L + private set + + fun start(what: () -> String): Long { + if (!uiTestHooksEnabled) return 0 + val id = nextId.incrementAndGet() + items[id] = Item(id, what()) + return id + } + + fun finish(id: Long) { + if (id == 0L) return + items.remove(id) + abandoned.remove(id) + } + + inline fun track(noinline what: (() -> String)?, block: () -> T): T { + val id = if (what == null) 0 else start(what) + try { + return block() + } finally { + finish(id) + } + } + + inline fun trackEvent(noinline what: () -> String, block: () -> T): T = + try { + track(what, block) + } finally { + if (uiTestHooksEnabled) eventProcessed() + } + + fun eventProcessed() { + lastEventAt = System.currentTimeMillis() + } + + fun inFlight(): List = items.values.filter { it.id !in abandoned }.sortedBy { it.id } + + fun abandon(stuck: List) { + stuck.forEach { abandoned.add(it.id) } + } +} 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 e4a187783a..b9cf956091 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 @@ -34,6 +34,9 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs 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.* import chat.simplex.common.views.migration.MigrateToDeviceView @@ -134,7 +137,7 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { } } } - ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester) + ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester, uiKey = Ui.NewProfile.Name) Spacer(Modifier.height(DEFAULT_PADDING)) @@ -157,6 +160,7 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_check), stringResource(MR.strings.create_another_profile_button), + uiKey = Ui.NewProfile.Create, disabled = !canCreateProfile(displayName.value) || !bioFitsLimit(shortDescr.value), textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary, @@ -213,7 +217,7 @@ private fun RowScope.MigrateButton(refocusTrigger: MutableState) { } } }, - modifier = Modifier.padding(end = DEFAULT_PADDING_HALF) + modifier = Modifier.padding(end = DEFAULT_PADDING_HALF).ui(Ui.Onboarding.Migrate) ) { Icon(painterResource(MR.images.ic_download), null, Modifier.size(22.dp), tint = MaterialTheme.colors.primary) Spacer(Modifier.width(4.dp)) @@ -283,13 +287,13 @@ private fun CreateFirstProfileMobile(chatModel: ChatModel, close: () -> Unit) { modifier = Modifier.padding(top = DEFAULT_PADDING_HALF) ) Spacer(Modifier.height(DEFAULT_PADDING_HALF)) - ProfileNameField(displayName, stringResource(MR.strings.enter_profile_name), { it.trim() == mkValidName(it) }, focusRequester) + ProfileNameField(displayName, stringResource(MR.strings.enter_profile_name), { it.trim() == mkValidName(it) }, focusRequester, uiKey = Ui.Onboarding.ProfileName) Spacer(Modifier.weight(1f)) Column(Modifier.widthIn(max = 450.dp).padding(bottom = DEFAULT_PADDING * 2).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingActionButton( - Modifier.fillMaxWidth(), + Modifier.fillMaxWidth().ui(Ui.Onboarding.CreateProfile), labelId = MR.strings.create_profile, onboarding = null, enabled = canCreateProfile(displayName.value), @@ -328,12 +332,12 @@ private fun CreateFirstProfileDesktop(chatModel: ChatModel, close: () -> Unit) { Spacer(Modifier.height(DEFAULT_PADDING)) ReadableText(MR.strings.onboarding_no_account, TextAlign.Center, style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.secondary)) Spacer(Modifier.height(DEFAULT_PADDING)) - ProfileNameField(displayName, stringResource(MR.strings.enter_profile_name), { it.trim() == mkValidName(it) }, focusRequester) + ProfileNameField(displayName, stringResource(MR.strings.enter_profile_name), { it.trim() == mkValidName(it) }, focusRequester, uiKey = Ui.Onboarding.ProfileName) } Spacer(Modifier.fillMaxHeight().weight(1f)) Column(Modifier.widthIn(max = 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingActionButton( - Modifier.widthIn(min = 300.dp), + Modifier.widthIn(min = 300.dp).ui(Ui.Onboarding.CreateProfile), labelId = MR.strings.create_profile, onboarding = null, enabled = canCreateProfile(displayName.value), @@ -412,7 +416,7 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () } @Composable -fun ProfileNameField(name: MutableState, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) { +fun ProfileNameField(name: MutableState, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null, uiKey: UiKey? = null) { var valid by rememberSaveable { mutableStateOf(true) } var focused by rememberSaveable { mutableStateOf(false) } val strokeColor by remember { @@ -427,6 +431,7 @@ fun ProfileNameField(name: MutableState, placeholder: String = "", isVal } } val modifier = Modifier + .ui(uiKey) .fillMaxWidth() .heightIn(min = 50.dp) .onFocusChanged { focused = it.isFocused } @@ -453,7 +458,7 @@ fun ProfileNameField(name: MutableState, placeholder: String = "", isVal leadingIcon = null, trailingIcon = if (!valid && placeholder != "") { { - IconButton({ showInvalidNameAlert(mkValidName(name.value), name) }, Modifier.size(20.dp)) { + IconButton({ showInvalidNameAlert(mkValidName(name.value), name) }, Modifier.size(20.dp).ui(uiKey?.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 9c019ad535..73d8318206 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 @@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.views.call.CallMediaType import chat.simplex.common.views.chatlist.* import androidx.compose.ui.text.* @@ -266,7 +268,7 @@ private fun deleteContactOrConversationDialog(chat: Chat, contact: Contact, chat SectionItemView({ AlertManager.shared.hideAlert() deleteActiveContactDialog(chat, contact, chatModel, close) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteContact")) { Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } // Cancel @@ -347,7 +349,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, "deleteAndNotify")) { Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } // Cancel @@ -833,9 +835,9 @@ fun LocalAliasEditor( } var updatedValueAtLeastOnce = remember { false } val modifier = if (center) - Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp) + Modifier.ui(Ui.Info.Alias).padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).widthIn(min = 100.dp) else - Modifier.padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).fillMaxWidth() + Modifier.ui(Ui.Info.Alias).padding(horizontal = if (!leadingIcon) DEFAULT_PADDING else 0.dp).fillMaxWidth() Row(Modifier.fillMaxWidth(), horizontalArrangement = if (center) Arrangement.Center else Arrangement.Start) { DefaultBasicTextField( modifier, @@ -1212,6 +1214,7 @@ fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit, disabled: Bo click = onClick, iconColor = MaterialTheme.colors.secondary, disabled = disabled, + uiKey = Ui.Info.VerifyCode, ) } @@ -1267,6 +1270,7 @@ private fun DeleteContactButton(onClick: () -> Unit) { click = onClick, textColor = Color.Red, iconColor = Color.Red, + uiKey = Ui.Info.DeleteContact, ) } @@ -1278,6 +1282,7 @@ fun ShareAddressButton(onClick: () -> Unit) { onClick, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, + uiKey = Ui.Address.Share, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index e9dbb3b157..502088d03a 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 @@ -33,6 +33,8 @@ import chat.simplex.common.model.CIDirection.GroupRcv import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.activeCall import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.group.* @@ -1294,7 +1296,7 @@ fun BoxScope.ChatInfoToolbar( } } menuItems.add { - ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { + ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), uiKey = Ui.Chat.BarMenuItem("search"), onClick = { showMenu.value = false showSearch.value = true }) @@ -1324,7 +1326,7 @@ fun BoxScope.ChatInfoToolbar( } } menuItems.add { - ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { + ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), uiKey = Ui.Chat.BarMenuItem("search"), onClick = { showMenu.value = false showSearch.value = true }) @@ -1422,7 +1424,7 @@ fun BoxScope.ChatInfoToolbar( if (menuItems.isNotEmpty()) { barButtons.add { - IconButton({ showMenu.value = true }) { + IconButton({ showMenu.value = true }, Modifier.ui(Ui.Chat.BarMenu)) { Icon(MoreVertFilled, stringResource(MR.strings.icon_descr_more_button), tint = MaterialTheme.colors.primary) } } @@ -1444,6 +1446,7 @@ fun BoxScope.ChatInfoToolbar( navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } }, title = { ChatInfoToolbarTitle(chatInfo) }, onTitleClick = if (chatInfo is ChatInfo.Local) null else info, + uiKey = Ui.Chat.Bar, showSearch = showSearch.value, searchAlwaysVisible = contentFilter.value != null, onTop = !oneHandUI.value || !chatBottomBar.value, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt index c10e28ce11..d6011feda6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.views.chatlist.acceptContactRequest import chat.simplex.common.views.chatlist.rejectContactRequest import chat.simplex.common.views.helpers.* @@ -63,7 +65,7 @@ fun ComposeContextContactRequestActionsView( .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 { showRejectRequestAlert(rhId, contactRequestId) } @@ -83,7 +85,7 @@ fun ComposeContextContactRequestActionsView( 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 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 92ccd92f93..81bd12df08 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 @@ -32,6 +32,7 @@ import chat.simplex.common.model.* 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.theme.* import chat.simplex.common.views.badges.openBadgesView import chat.simplex.common.views.chat.group.hostFromRelayLink @@ -1546,6 +1547,7 @@ fun ComposeView( style = MaterialTheme.typography.body2, color = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, disabled = composeState.value.inProgress, + uiKey = Ui.Chat.Connect, click = { withApi { sendRequest() } } ) } 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 3f3278ee58..5e252dae3a 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 @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp 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.views.helpers.* import dev.icerock.moko.resources.compose.stringResource import chat.simplex.res.* @@ -43,7 +44,8 @@ fun BoxScope.SelectedItemsCounterToolbar(selectedItems: MutableState?> onTitleClick = null, onTop = onTop, onSearchValueChanged = {}, - buttons = if (selectAll != null) { { SelectAllButton(selectAll) } } else {{}} + buttons = if (selectAll != null) { { SelectAllButton(selectAll) } } else {{}}, + uiKey = Ui.Chat.Selection ) } } 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 8205071225..eddb5d727d 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,6 +20,8 @@ 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.ui import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatItem @@ -462,7 +464,7 @@ private fun SendMsgButton( val interactionSource = remember { MutableInteractionSource() } val ripple = remember { ripple(bounded = false, radius = 24.dp) } Box( - modifier = Modifier.requiredSize(36.dp) + modifier = Modifier.ui(Ui.Chat.Send).requiredSize(36.dp) .combinedClickable( onClick = { if (sendToConnect != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt index 3610147804..931ebc5be6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt @@ -18,6 +18,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCode @@ -79,6 +81,7 @@ private fun VerifyCodeLayout( SelectionContainer(Modifier.padding(vertical = DEFAULT_PADDING_HALF, horizontal = DEFAULT_PADDING_HALF)) { Text( splitCode, + Modifier.ui(Ui.VerifyCode.Code), fontFamily = FontFamily.Monospace, fontSize = 18.sp, maxLines = 20 @@ -114,7 +117,7 @@ private fun VerifyCodeLayout( } } } - SimpleButton(generalGetString(MR.strings.mark_code_verified), painterResource(MR.images.ic_verified_user)) { + SimpleButton(generalGetString(MR.strings.mark_code_verified), painterResource(MR.images.ic_verified_user), uiKey = Ui.VerifyCode.Mark) { withApi { val verified = verifyCode(connectionCode) if (!verified) { 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 731b125955..86380f9580 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -30,6 +30,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.res.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -248,6 +249,7 @@ fun InviteMembersButton(titleId: StringResource, onClick: () -> Unit, disabled: textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary, disabled = disabled, + uiKey = Ui.InviteMembers.Invite, ) } @@ -349,7 +351,8 @@ fun ContactCheckRow( else removeContact(contact.apiId) } - } else null + } else null, + uiKey = Ui.InviteMembers.Contact(contact.contactId) ) { ProfileImage(size = 36.dp, contact.image) Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) 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 973b684d38..203c3a643a 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 @@ -39,6 +39,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.helpers.* import chat.simplex.common.views.usersettings.* @@ -794,7 +795,8 @@ fun ModalData.GroupChatInfoLayout( }, longClick = { showMenu.value = true }, minHeight = 54.dp, - padding = PaddingValues(horizontal = DEFAULT_PADDING) + padding = PaddingValues(horizontal = DEFAULT_PADDING), + uiKey = Ui.GroupInfo.Member(member.groupMemberId) ) { Box(contentAlignment = Alignment.CenterStart) { androidx.compose.animation.AnimatedVisibility(selectedItems.value != null, enter = fadeIn(), exit = fadeOut()) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index ac1c6915b4..c41a3fe032 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* @@ -314,7 +315,7 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c SectionItemView({ AlertManager.shared.hideAlert() removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) - }) { + }, uiKey = Ui.Alert.Option(Ui.AlertSource.Shared, "remove")) { Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) } SectionItemView({ @@ -850,6 +851,7 @@ fun RemoveMemberButton(useRelays: Boolean = false, isRelay: Boolean = false, onC click = onClick, textColor = Color.Red, iconColor = Color.Red, + uiKey = Ui.MemberInfo.Remove, ) } @@ -921,6 +923,7 @@ private fun RoleSelectionRow( selectedRole, icon = null, enabled = remember { mutableStateOf(true) }, + uiKey = Ui.MemberInfo.Role, onSelected = onSelected ) } 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 822aef9aa2..d26e47ccc6 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 @@ -31,6 +31,9 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.currentUser import chat.simplex.common.platform.* 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.* import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.helpers.* @@ -153,7 +156,7 @@ fun ChatItemView( } } - var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp)) + var modifier = Modifier.ui(Ui.Chat.ReactionChip(cItem.id, r.reaction.text)).padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp)) if (cInfo.featureEnabled(ChatFeature.Reactions)) { fun showReactionsMenu() { when (cInfo) { @@ -322,6 +325,7 @@ fun ChatItemView( } Column( Modifier + .ui(Ui.Chat.Item(cItem.id)) .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) .hoverable(bubbleInteractionSource) .combinedClickable( @@ -360,7 +364,7 @@ fun ChatItemView( Row(modifier = Modifier.padding(horizontal = DEFAULT_PADDING).horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically) { rs.forEach() { r -> Box( - Modifier.size(36.dp).clip(CircleShape).clickable { + Modifier.ui(Ui.Chat.ItemReaction(cItem.id, r.text)).size(36.dp).clip(CircleShape).clickable { setReaction(cInfo, cItem, true, r) showMenu.value = false }, @@ -379,7 +383,7 @@ fun ChatItemView( DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() - SelectItemAction(showMenu, selectChatItem) + SelectItemAction(showMenu, Ui.Chat.ItemMenu(cItem.id, "select"), selectChatItem) } } } @@ -396,7 +400,7 @@ fun ChatItemView( } DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) Divider() - SelectItemAction(showMenu, selectChatItem) + SelectItemAction(showMenu, Ui.Chat.ItemMenu(cItem.id, "select"), selectChatItem) } } cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> { @@ -405,7 +409,7 @@ fun ChatItemView( MsgReactionsMenu() } if (cItem.meta.itemDeleted == null && !live && !cItem.localNote && cInfo.sendMsgEnabled) { - ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { + ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), uiKey = Ui.Chat.ItemMenu(cItem.id, "reply"), onClick = { if (composeState.value.editing) { composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) } else { @@ -462,7 +466,7 @@ fun ChatItemView( }) } if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { - ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { + ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), uiKey = Ui.Chat.ItemMenu(cItem.id, "edit"), onClick = { composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) showMenu.value = false }) @@ -471,7 +475,7 @@ fun ChatItemView( (cItem.file == null || cItem.file.forwardingAllowed()) && !cItem.isLiveDummy && !live ) { - ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = { + ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), uiKey = Ui.Chat.ItemMenu(cItem.id, "forward"), onClick = { forwardItem(cInfo, cItem) showMenu.value = false }) @@ -496,7 +500,7 @@ fun ChatItemView( } if (cItem.canBeDeletedForSelf) { Divider() - SelectItemAction(showMenu, selectChatItem) + SelectItemAction(showMenu, Ui.Chat.ItemMenu(cItem.id, "select"), selectChatItem) } } } @@ -513,7 +517,7 @@ fun ChatItemView( DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() - SelectItemAction(showMenu, selectChatItem) + SelectItemAction(showMenu, Ui.Chat.ItemMenu(cItem.id, "select"), selectChatItem) } } } @@ -523,7 +527,7 @@ fun ChatItemView( DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() - SelectItemAction(showMenu, selectChatItem) + SelectItemAction(showMenu, Ui.Chat.ItemMenu(cItem.id, "select"), selectChatItem) } } } @@ -537,7 +541,7 @@ fun ChatItemView( DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() - SelectItemAction(showMenu, selectChatItem) + SelectItemAction(showMenu, Ui.Chat.ItemMenu(cItem.id, "select"), selectChatItem) } } } @@ -546,7 +550,7 @@ fun ChatItemView( DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (selectedChatItems.value == null) { Divider() - SelectItemAction(showMenu, selectChatItem) + SelectItemAction(showMenu, Ui.Chat.ItemMenu(cItem.id, "select"), selectChatItem) } } } @@ -563,7 +567,7 @@ fun ChatItemView( DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() - SelectItemAction(showMenu, selectChatItem) + SelectItemAction(showMenu, Ui.Chat.ItemMenu(cItem.id, "select"), selectChatItem) } } } @@ -592,7 +596,7 @@ fun ChatItemView( DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() - SelectItemAction(showMenu, selectChatItem) + SelectItemAction(showMenu, Ui.Chat.ItemMenu(cItem.id, "select"), selectChatItem) } } } @@ -666,7 +670,7 @@ fun ChatItemView( DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() - SelectItemAction(showMenu, selectChatItem) + SelectItemAction(showMenu, Ui.Chat.ItemMenu(cItem.id, "select"), selectChatItem) } } } @@ -880,6 +884,7 @@ fun DeleteItemAction( ItemAction( buttonText, painterResource(MR.images.ic_delete), + uiKey = Ui.Chat.ItemMenu(cItem.id, "delete"), onClick = { showMenu.value = false if (!revealed.value) { @@ -935,11 +940,13 @@ fun ModerateItemAction( @Composable fun SelectItemAction( showMenu: MutableState, + uiKey: UiKey? = null, selectItem: () -> Unit, ) { ItemAction( stringResource(MR.strings.select_verb), painterResource(MR.images.ic_check_circle), + uiKey = uiKey, onClick = { showMenu.value = false selectItem() @@ -1093,11 +1100,11 @@ fun showArchiveReportsAlert(ids: List, allowForAll: Boolean, archiveReport } @Composable -fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) { +fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, uiKey: UiKey? = null, onClick: () -> Unit) { val finalColor = if (color == Color.Unspecified) { MenuTextColor } else color - DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + DropdownMenuItem(onClick, Modifier.ui(uiKey), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text, @@ -1113,11 +1120,11 @@ fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, on } @Composable -fun ItemAction(text: String, icon: ImageBitmap, textColor: Color = Color.Unspecified, iconColor: Color = Color.Unspecified, onClick: () -> Unit) { +fun ItemAction(text: String, icon: ImageBitmap, textColor: Color = Color.Unspecified, iconColor: Color = Color.Unspecified, uiKey: UiKey? = null, onClick: () -> Unit) { val finalColor = if (textColor == Color.Unspecified) { MenuTextColor } else textColor - DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + DropdownMenuItem(onClick, Modifier.ui(uiKey), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text, @@ -1144,12 +1151,13 @@ fun ItemAction( composable: @Composable () -> Unit, color: Color = Color.Unspecified, onClick: () -> Unit, - lineLimit: Int = Int.MAX_VALUE + lineLimit: Int = Int.MAX_VALUE, + uiKey: UiKey? = null, ) { val finalColor = if (color == Color.Unspecified) { MenuTextColor } else color - DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + DropdownMenuItem(onClick, Modifier.ui(uiKey), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text, @@ -1167,11 +1175,11 @@ fun ItemAction( } @Composable -fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = Color.Unspecified) { +fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = Color.Unspecified, uiKey: UiKey? = null) { val finalColor = if (color == Color.Unspecified) { MenuTextColor } else color - DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + DropdownMenuItem(onClick, Modifier.ui(uiKey), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text, @@ -1187,11 +1195,11 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo } @Composable -fun ItemAction(text: String, color: Color = Color.Unspecified, onClick: () -> Unit) { +fun ItemAction(text: String, color: Color = Color.Unspecified, uiKey: UiKey? = null, onClick: () -> Unit) { val finalColor = if (color == Color.Unspecified) { MenuTextColor } else color - DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + DropdownMenuItem(onClick, Modifier.ui(uiKey), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { Text( text, modifier = Modifier @@ -1409,14 +1417,14 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, chatInfo: TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() - }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + }, Modifier.ui(Ui.Alert.Option(Ui.AlertSource.Shared, "forMe"))) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } } if (canDeleteForEveryone) { Spacer(Modifier.padding(horizontal = 4.dp)) TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) 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/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 32e0befa3f..e917d75d99 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 @@ -21,6 +21,8 @@ import androidx.compose.ui.unit.sp 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.UiKey import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.group.* @@ -80,6 +82,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + uiKey = Ui.ChatList.Row(chat.id, chat.remoteHostId), ) } is ChatInfo.Group -> { @@ -100,6 +103,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + uiKey = Ui.ChatList.Row(chat.id, chat.remoteHostId), ) } is ChatInfo.Local -> { @@ -120,6 +124,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + uiKey = Ui.ChatList.Row(chat.id, chat.remoteHostId), ) } is ChatInfo.ContactRequest -> @@ -132,13 +137,14 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) { onRequestAccepted(it) } }, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { - ContactRequestMenuItems(chat.remoteHostId, contactRequestId = chat.chatInfo.apiId, chatModel, showMenu) + ContactRequestMenuItems(chat.remoteHostId, contactRequestId = chat.chatInfo.apiId, chatModel, showMenu, chatId = chat.id) } }, showMenu, disabled, selectedChat, nextChatSelected, + uiKey = Ui.ChatList.Row(chat.id, chat.remoteHostId), ) is ChatInfo.ContactConnection -> ChatListNavLinkLayout( @@ -159,6 +165,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + uiKey = Ui.ChatList.Row(chat.id, chat.remoteHostId), ) is ChatInfo.InvalidJSON -> ChatListNavLinkLayout( @@ -175,6 +182,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { disabled, selectedChat, nextChatSelected, + uiKey = Ui.ChatList.Row(chat.id, chat.remoteHostId), ) } } @@ -278,7 +286,7 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { if (contact.nextAcceptContactRequest) { if (contact.contactRequestId != null) { - ContactRequestMenuItems(chat.remoteHostId, contactRequestId = contact.contactRequestId, chatModel, showMenu) + ContactRequestMenuItems(chat.remoteHostId, contactRequestId = contact.contactRequestId, chatModel, showMenu, chatId = chat.id) } else if (contact.groupDirectInv != null && !contact.groupDirectInv.memberRemoved) { MemberContactRequestMenuItems(chat.remoteHostId, contact, showMenu) } else { @@ -366,6 +374,7 @@ fun MarkReadChatAction(chat: Chat, showMenu: MutableState) { ItemAction( stringResource(MR.strings.mark_read), painterResource(MR.images.ic_check), + uiKey = Ui.ChatList.RowMenu(chat.id, "markRead", chat.remoteHostId), onClick = { markChatRead(chat) ntfManager.cancelNotificationsForChat(chat.id) @@ -379,6 +388,7 @@ fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableStat ItemAction( stringResource(MR.strings.mark_unread), painterResource(MR.images.ic_mark_chat_unread), + uiKey = Ui.ChatList.RowMenu(chat.id, "markUnread", chat.remoteHostId), onClick = { markChatUnread(chat, chatModel) showMenu.value = false @@ -395,6 +405,7 @@ fun TagListAction( ItemAction( stringResource(if (chat.chatInfo.chatTags.isNullOrEmpty()) MR.strings.add_to_list else MR.strings.change_list), painterResource(MR.images.ic_label), + uiKey = Ui.ChatList.RowMenu(chat.id, "tagList", chat.remoteHostId), onClick = { ModalManager.start.showModalCloseable { close -> if (userTags.value.isEmpty()) { @@ -413,6 +424,7 @@ fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolea ItemAction( if (favorite) stringResource(MR.strings.unfavorite_chat) else stringResource(MR.strings.favorite_chat), if (favorite) painterResource(MR.images.ic_star_off) else painterResource(MR.images.ic_star), + uiKey = Ui.ChatList.RowMenu(chat.id, if (favorite) "unfavorite" else "favorite", chat.remoteHostId), onClick = { toggleChatFavorite(chat.remoteHostId, chat.chatInfo, !favorite, chatModel) showMenu.value = false @@ -425,6 +437,7 @@ fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, nextMsgFilte ItemAction( generalGetString(nextMsgFilter.text(chat.chatInfo.hasMentions)), painterResource(nextMsgFilter.icon), + uiKey = Ui.ChatList.RowMenu(chat.id, "ntfs.${nextMsgFilter.name}", chat.remoteHostId), onClick = { toggleNotifications(chat.remoteHostId, chat.chatInfo, nextMsgFilter, chatModel) showMenu.value = false @@ -441,7 +454,8 @@ fun ClearChatAction(chat: Chat, showMenu: MutableState) { clearChatDialog(chat) showMenu.value = false }, - color = WarningOrange + color = WarningOrange, + uiKey = Ui.ChatList.RowMenu(chat.id, "clear", chat.remoteHostId) ) } @@ -454,7 +468,8 @@ fun ClearNoteFolderAction(chat: Chat, showMenu: MutableState) { clearNoteFolderDialog(chat) showMenu.value = false }, - color = WarningOrange + color = WarningOrange, + uiKey = Ui.ChatList.RowMenu(chat.id, "clear", chat.remoteHostId) ) } @@ -467,7 +482,8 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState deleteContactDialog(chat, chatModel) showMenu.value = false }, - color = Color.Red + color = Color.Red, + uiKey = Ui.ChatList.RowMenu(chat.id, "delete", chat.remoteHostId) ) } @@ -519,16 +535,18 @@ fun LeaveGroupAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, sh leaveGroupDialog(rhId, groupInfo, chatModel) showMenu.value = false }, - color = Color.Red + color = Color.Red, + uiKey = Ui.ChatList.RowMenu(groupInfo.id, "leave", rhId) ) } @Composable -fun ContactRequestMenuItems(rhId: Long?, contactRequestId: Long, chatModel: ChatModel, showMenu: MutableState, onSuccess: ((chat: Chat) -> Unit)? = null) { +fun ContactRequestMenuItems(rhId: Long?, contactRequestId: Long, chatModel: ChatModel, showMenu: MutableState, onSuccess: ((chat: Chat) -> Unit)? = null, chatId: String? = null) { ItemAction( stringResource(MR.strings.accept_contact_button), painterResource(MR.images.ic_check), color = MaterialTheme.colors.onBackground, + uiKey = chatId?.let { Ui.ChatList.RowMenu(it, "accept", rhId) }, onClick = { acceptContactRequest(rhId, incognito = false, contactRequestId, true, chatModel, onSuccess) showMenu.value = false @@ -539,6 +557,7 @@ fun ContactRequestMenuItems(rhId: Long?, contactRequestId: Long, chatModel: Chat stringResource(MR.strings.accept_contact_incognito_button), painterResource(MR.images.ic_theater_comedy), color = MaterialTheme.colors.onBackground, + uiKey = chatId?.let { Ui.ChatList.RowMenu(it, "acceptIncognito", rhId) }, onClick = { acceptContactRequest(rhId, incognito = true, contactRequestId, true, chatModel, onSuccess) showMenu.value = false @@ -552,7 +571,8 @@ fun ContactRequestMenuItems(rhId: Long?, contactRequestId: Long, chatModel: Chat rejectContactRequest(rhId, contactRequestId, chatModel) showMenu.value = false }, - color = Color.Red + color = Color.Red, + uiKey = chatId?.let { Ui.ChatList.RowMenu(it, "reject", rhId) } ) } @@ -591,6 +611,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection } showMenu.value = false }, + uiKey = Ui.ChatList.RowMenu(chatInfo.id, "setName", rhId), ) ItemAction( stringResource(MR.strings.delete_verb), @@ -601,7 +622,8 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection } showMenu.value = false }, - color = Color.Red + color = Color.Red, + uiKey = Ui.ChatList.RowMenu(chatInfo.id, "delete", rhId), ) } @@ -1032,6 +1054,7 @@ expect fun ChatListNavLinkLayout( disabled: Boolean, selectedChat: State, nextChatSelected: State, + uiKey: UiKey? = null, ) @Preview/*( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 17101c35d1..5b5e96abb0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -35,6 +35,9 @@ import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts +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 SectionItemView import chat.simplex.common.views.helpers.* @@ -142,6 +145,7 @@ fun ToggleChatListCard() { val selectedBg = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) Row( Modifier + .ui(Ui.ChatList.OneHandCard) .padding(horizontal = 16.dp, vertical = 12.dp) .fillMaxWidth() .height(IntrinsicSize.Min) @@ -154,7 +158,7 @@ fun ToggleChatListCard() { isSelected = oneHandUI.value, selectedBg = selectedBg, activeBg = activeBg, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f).ui(Ui.ChatList.OneHandCard.part("bottom")) ) { appPrefs.oneHandUI.set(true) } Box(Modifier.weight(1f).fillMaxHeight()) { ToolbarSegment( @@ -163,13 +167,14 @@ fun ToggleChatListCard() { isSelected = !oneHandUI.value, selectedBg = selectedBg, activeBg = activeBg, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().ui(Ui.ChatList.OneHandCard.part("top")) ) { appPrefs.oneHandUI.set(false) } Icon( painterResource(MR.images.ic_close), null, Modifier .align(Alignment.CenterEnd) .padding(end = 4.dp) + .ui(Ui.ChatList.OneHandCard.part("close")) .clip(CircleShape) .clickable(onClick = onClose) .padding(8.dp) @@ -381,7 +386,7 @@ private fun ConnectBannerCard() { .clip(RoundedCornerShape(18.dp)) ) { Column( - Modifier.weight(1f).clickable { + Modifier.weight(1f).ui(Ui.ChatList.ConnectBanner.part("newLink")).clickable { ModalManager.start.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) } @@ -412,7 +417,7 @@ private fun ConnectBannerCard() { } Spacer(Modifier.width(2.dp).fillMaxHeight().background(MaterialTheme.colors.background)) Column( - Modifier.weight(1f).clickable { + Modifier.weight(1f).ui(Ui.ChatList.ConnectBanner.part("pasteLink")).clickable { ModalManager.start.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = close) } @@ -483,7 +488,7 @@ private fun BoxScope.NewChatSheetFloatingButton(oneHandUI: State, stopp showNewChatSheet(oneHandUI) } }, - Modifier + Modifier.ui(Ui.ChatList.NewChatFab) .navigationBarsPadding() .padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING) .align(Alignment.BottomEnd) @@ -549,7 +554,7 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow generalGetString(MR.strings.chat_is_stopped_indication), generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app) ) - }) { + }, Modifier.ui(Ui.ChatList.Stopped)) { Icon( painterResource(MR.images.ic_report_filled), generalGetString(MR.strings.chat_is_stopped_indication), @@ -587,6 +592,7 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow onClick = { showNewChatSheet(oneHandUI) }, + Modifier.ui(Ui.ChatList.NewChat), ) { Box( contentAlignment = Alignment.Center, @@ -622,7 +628,7 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow val allRead = users .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } - UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { + UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead, Ui.ChatList.UserPicker) { userPickerState.value = AnimatedViewState.VISIBLE } } @@ -660,7 +666,8 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow onTitleClick = if (canScrollToZero.value) { { scrollToBottom(scope, listState) } } else null, onTop = !oneHandUI.value, onSearchValueChanged = {}, - buttons = { barButtons.forEach { it() } } + buttons = { barButtons.forEach { it() } }, + uiKey = Ui.ChatList.Bar, ) } @@ -694,6 +701,7 @@ fun SubscriptionStatusIndicator(click: (() -> Unit)) { SimpleButtonFrame( click = click, + Modifier.ui(Ui.ChatList.Subscriptions), disabled = chatModel.chatRunning.value != true ) { SubscriptionStatusIndicatorView(subs = subs, hasSess = hasSess) @@ -701,9 +709,9 @@ fun SubscriptionStatusIndicator(click: (() -> Unit)) { } @Composable -fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) { +fun UserProfileButton(image: String?, allRead: Boolean, uiKey: UiKey? = null, onButtonClicked: () -> Unit) { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onButtonClicked) { + IconButton(onClick = onButtonClicked, Modifier.ui(uiKey)) { Box { ProfileImage( image = image, @@ -753,7 +761,7 @@ private fun ToggleFilterEnabledButton() { } else { chatModel.activeChatTagFilter.value = ActiveFilter.Unread } - }) { + }, Modifier.ui(Ui.ChatList.UnreadFilter)) { val sp16 = with(LocalDensity.current) { 16.sp.toDp() } Icon( painterResource(MR.images.ic_filter_list), @@ -797,7 +805,7 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState tint = MaterialTheme.colors.secondary ) SearchTextField( - Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester), + Modifier.ui(Ui.ChatList.Search).weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester), placeholder = stringResource(MR.strings.search_or_paste_simplex_link), alwaysVisible = true, searchText = searchText, @@ -1208,6 +1216,7 @@ internal fun ConnectByNameRow(name: String, searchText: MutableState) { is ActiveFilter.UserTag -> Text(String.format(generalGetString(MR.strings.no_chats_in_list), activeFilter.tag.chatTagText), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) is ActiveFilter.Unread -> { Row( - Modifier.clip(shape = CircleShape).clickable { chatModel.activeChatTagFilter.value = null }.padding(DEFAULT_PADDING_HALF), + Modifier.ui(Ui.ChatList.NoUnreadChats).clip(shape = CircleShape).clickable { chatModel.activeChatTagFilter.value = null }.padding(DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -1314,6 +1323,7 @@ private fun TagsView(searchText: MutableState) { Box { Row( rowSizeModifier + .ui(Ui.ChatList.UserTag(tag.chatTagId)) .clip(shape = CircleShape) .combinedClickable( onClick = { @@ -1388,7 +1398,7 @@ private fun TagsView(searchText: MutableState) { } if (userTags.value.isEmpty()) { - Row(rowSizeModifier.clip(shape = CircleShape).then(plusClickModifier).padding(start = 2.dp, top = 4.dp, end = 6.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Row(rowSizeModifier.ui(Ui.ChatList.AddList).clip(shape = CircleShape).then(plusClickModifier).padding(start = 2.dp, top = 4.dp, end = 6.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.secondary) Spacer(Modifier.width(2.dp)) Text(stringResource(MR.strings.chat_list_add_list), color = MaterialTheme.colors.secondary, fontSize = 15.sp) @@ -1396,7 +1406,7 @@ private fun TagsView(searchText: MutableState) { } else { Box(rowSizeModifier, contentAlignment = Alignment.Center) { Icon( - painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.clip(shape = CircleShape).then(plusClickModifier).padding(2.dp), tint = MaterialTheme.colors.secondary + painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.ui(Ui.ChatList.AddList).clip(shape = CircleShape).then(plusClickModifier).padding(2.dp), tint = MaterialTheme.colors.secondary ) } } @@ -1418,6 +1428,7 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) { Row( modifier = Modifier + .ui(Ui.ChatList.PresetTag(tag.name)) .sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) .clip(shape = CircleShape) .clickable { @@ -1469,6 +1480,7 @@ private fun CollapsedTagsFilterView(searchText: MutableState) { } Box(Modifier + .ui(Ui.ChatList.PresetMenu) .clip(shape = CircleShape) .size(TAG_MIN_HEIGHT * fontSizeSqrtMultiplier) .clickable { showMenu.value = true }, @@ -1497,6 +1509,7 @@ private fun CollapsedTagsFilterView(searchText: MutableState) { ItemAction( stringResource(MR.strings.chat_list_all), painterResource(MR.images.ic_menu), + uiKey = Ui.ChatList.PresetMenuAll, onClick = { onCloseMenuAction.value = { searchText.value = TextFieldValue() @@ -1528,6 +1541,7 @@ fun ItemPresetFilterAction( stringResource(text), painterResource(icon), color = if (active) MaterialTheme.colors.primary else Color.Unspecified, + uiKey = Ui.ChatList.PresetMenuItem(presetTag.name), onClick = { onCloseMenuAction.value = { chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag) 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 31941e1f77..e44293ad55 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 @@ -47,6 +47,7 @@ import chat.simplex.common.model.ServerSessions import chat.simplex.common.model.XFTPServerSummary import chat.simplex.common.model.localTimestamp import chat.simplex.common.platform.* +import chat.simplex.common.ui.Ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.SettingsPreferenceItem @@ -346,7 +347,7 @@ private fun SMPSubscriptionsSection(totals: SMPTotals) { generalGetString(MR.strings.servers_info_subscriptions_total), numOrDash(totals.subs.total) ) - SettingsPreferenceItem(null, stringResource(MR.strings.subscription_percentage), chatModel.controller.appPrefs.networkShowSubscriptionPercentage) + SettingsPreferenceItem(null, stringResource(MR.strings.subscription_percentage), chatModel.controller.appPrefs.networkShowSubscriptionPercentage, uiKey = Ui.ServersSummary.ShowPercentage) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt index b1fe42cdfc..87f87f0346 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListNavLinkView.kt @@ -10,6 +10,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +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.res.* @@ -37,7 +39,8 @@ fun ShareListNavLinkView( scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } } }, - stopped + stopped, + uiKey = Ui.Share.Row(chat.id) ) } is ChatInfo.Group -> { @@ -54,14 +57,16 @@ fun ShareListNavLinkView( scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel) } } }, - stopped + stopped, + uiKey = Ui.Share.Row(chat.id) ) } is ChatInfo.Local -> ShareListNavLinkLayout( chatLinkPreview = { SharePreviewView(chat, disabled = false) }, click = { scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } }, - stopped + stopped, + uiKey = Ui.Share.Row(chat.id) ) is ChatInfo.ContactRequest, is ChatInfo.ContactConnection, is ChatInfo.InvalidJSON -> {} } @@ -79,8 +84,9 @@ private fun ShareListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, click: () -> Unit, stopped: Boolean, + uiKey: UiKey, ) { - SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 8.dp), click = click, disabled = stopped) { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 8.dp), click = click, disabled = stopped, uiKey = uiKey) { chatLinkPreview() } Divider(Modifier.padding(horizontal = 8.dp)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index 17e288ff00..4eae4a2161 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -32,6 +32,8 @@ import chat.simplex.common.model.ChatController.apiSetChatTags import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.clearActiveChatFilterIfNeeded 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.chat.item.ReactionIcon @@ -110,6 +112,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Row( Modifier + .ui(Ui.TagList.Tag(tag.chatTagId)) .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) .clickable( @@ -261,7 +264,7 @@ fun ModalData.TagListEditor( trimmedName.value.isEmpty() || isDuplicateEmojiOrName.value - SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled) { + SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled, uiKey = Ui.TagEditor.Save) { Text( generalGetString(if (chat != null) MR.strings.add_to_list else MR.strings.save_list), color = if (disabled) colors.secondary else colors.primary @@ -311,7 +314,8 @@ private fun DeleteTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState, showError: State) onValueChange = { name.value = it }, interactionSource = interactionSource, modifier = Modifier + .ui(Ui.TagEditor.Name) .fillMaxWidth() .indicatorLine(true, showError.value, interactionSource, colors) .heightIn(min = TextFieldDefaults.MinHeight) @@ -483,7 +489,7 @@ private fun deleteTagDialog(rhId: Long?, tag: ChatTag, saving: MutableState Unit)? = null, disabled: Boolean = false) { - SectionItemView(click, disabled = disabled, extraPadding = appPlatform.isDesktop) { +fun UserPickerOptionRow(icon: Painter, text: String, click: (() -> Unit)? = null, disabled: Boolean = false, uiKey: UiKey? = null) { + SectionItemView(click, disabled = disabled, extraPadding = appPlatform.isDesktop, uiKey = uiKey) { Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.secondary) TextIconSpaced() Text(text = text, color = if (disabled) MaterialTheme.colors.secondary else Color.Unspecified) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 9c64e7ce85..63cc48c13a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.ChatModel import chat.simplex.common.model.LocalBadge 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.res.* import dev.icerock.moko.resources.StringResource @@ -155,11 +157,11 @@ class AlertManager { TextButton(onClick = { onDismiss?.invoke() hideAlert() - }) { Text(dismissText) } + }, Modifier.ui(Ui.Alert.Dismiss(uiSource))) { Text(dismissText) } TextButton(onClick = { onConfirm?.invoke() hideAlert() - }, Modifier.focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } + }, Modifier.ui(Ui.Alert.Confirm(uiSource)).focusRequester(focusRequester)) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) } } } if (parseHtml) { @@ -196,11 +198,11 @@ class AlertManager { TextButton(onClick = { onDismiss?.invoke() hideAlert() - }) { Text(dismissText) } + }, Modifier.ui(Ui.Alert.Dismiss(uiSource))) { Text(dismissText) } TextButton(onClick = { onConfirm?.invoke() hideAlert() - }) { Text(confirmText, color = if (destructive) Color.Red else Color.Unspecified, textAlign = TextAlign.End) } + }, Modifier.ui(Ui.Alert.Confirm(uiSource))) { Text(confirmText, color = if (destructive) Color.Red else Color.Unspecified, textAlign = TextAlign.End) } } } }, @@ -246,7 +248,7 @@ class AlertManager { onConfirm?.invoke() hideAlert() }, - Modifier.focusRequester(focusRequester) + Modifier.ui(Ui.Alert.Confirm(uiSource)).focusRequester(focusRequester) ) { Text(confirmText, color = Color.Unspecified) } @@ -400,7 +402,7 @@ class AlertManager { TextButton(onClick = { onConfirm.invoke() hideAlert() - }, Modifier.focusRequester(focusRequester)) { + }, Modifier.ui(Ui.Alert.Confirm(uiSource)).focusRequester(focusRequester)) { Text(confirmText) } } @@ -415,7 +417,7 @@ class AlertManager { TextButton(onClick = { onDismiss?.invoke() hideAlert() - }, if (confirmText == null) Modifier.focusRequester(focusRequester) else Modifier) { + }, Modifier.ui(Ui.Alert.Dismiss(uiSource)).then(if (confirmText == null) Modifier.focusRequester(focusRequester) else Modifier)) { Text(dismissText) } } @@ -427,6 +429,9 @@ class AlertManager { } } + private val uiSource: Ui.AlertSource + get() = if (this === privacySensitive) Ui.AlertSource.PrivacySensitive else Ui.AlertSource.Shared + @Composable fun showInView() { alertViews.collectAsState().value.lastOrNull()?.invoke() 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 1c2ef93a09..a6ff2d233c 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 @@ -15,6 +15,8 @@ import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.ChatController.appPrefs +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.CenteredRowLayout import chat.simplex.res.* @@ -33,10 +35,11 @@ fun DefaultAppBar( onSearchValueChanged: (String) -> Unit = {}, searchTrailingContent: @Composable (() -> Unit)? = null, buttons: @Composable RowScope.() -> Unit = {}, + uiKey: UiKey? = null, ) { // If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier val modifier = if (!showSearch) { - Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { }) + Modifier.ui(uiKey?.part("title")).clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { }) } else if (!onTop) Modifier.imePadding() else Modifier @@ -81,7 +84,7 @@ fun DefaultAppBar( title = { if (showSearch) { val placeholder = searchPlaceholder ?: stringResource(MR.strings.search_verb) - SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, placeholder = placeholder, trailingContent = searchTrailingContent, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged) + SearchTextField(Modifier.ui(uiKey?.part("search")).fillMaxWidth(), alwaysVisible = searchAlwaysVisible, placeholder = placeholder, trailingContent = searchTrailingContent, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged) } else if (title != null) { title() } else if (titleText.value.isNotEmpty() && connection != null) { 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 2867f6d8a3..b646fc31fa 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 @@ -17,6 +17,8 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.res.* +import chat.simplex.common.ui.UiKey +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import dev.icerock.moko.resources.ImageResource @@ -31,6 +33,7 @@ fun ExposedDropDownSetting( enabled: State = mutableStateOf(true), minWidth: Dp = 200.dp, maxWidth: Dp = with(LocalDensity.current) { 180.sp.toDp() }, + uiKey: UiKey? = null, onSelected: (T) -> Unit ) { val expanded = remember { mutableStateOf(false) } @@ -38,7 +41,8 @@ fun ExposedDropDownSetting( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value && enabled.value - } + }, + modifier = Modifier.ui(uiKey) ) { Row( Modifier.padding(start = 10.dp), @@ -70,6 +74,7 @@ fun ExposedDropDownSetting( onSelected(selectionOption.first) expanded.value = false }, + modifier = Modifier.ui(uiKey?.option(selectionOption.first ?: "null")), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f) ) { Text( @@ -164,9 +169,10 @@ fun ExposedDropDownSettingRow( enabled: State = mutableStateOf(true), minWidth: Dp = 200.dp, maxWidth: Dp = with(LocalDensity.current) { 180.sp.toDp() }, + uiKey: UiKey? = null, onSelected: (T) -> Unit ) { SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) { - ExposedDropDownSetting(values, selection ,textColor, label = label, enabled = enabled, minWidth = minWidth, maxWidth = maxWidth, onSelected = onSelected) + ExposedDropDownSetting(values, selection ,textColor, label = label, enabled = enabled, minWidth = minWidth, maxWidth = maxWidth, uiKey = uiKey, onSelected = onSelected) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt index 27064b21d5..19aede73ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ProcessedErrors.kt @@ -16,7 +16,7 @@ class ProcessedErrors (val interval: Long) { fun newError(error: T, offerRestart: Boolean) { timer.cancel() - timer = withLongRunningApi(slow = 130_000) { + timer = withLongRunningApi(slow = 130_000, idle = false) { val delayBeforeNext = (lastShownTimestamp + interval) - System.currentTimeMillis() if ((lastShownOfferRestart || !offerRestart) && delayBeforeNext >= 0) { delay(delayBeforeNext) 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 32370ce290..04893b3242 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 @@ -22,6 +22,8 @@ import androidx.compose.ui.unit.* import androidx.compose.ui.text.font.FontWeight import chat.simplex.common.platform.onRightClick import chat.simplex.common.platform.windowWidth +import chat.simplex.common.ui.UiKey +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.SelectableCard @@ -204,9 +206,11 @@ fun SectionItemView( PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL, bottom = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) else PaddingValues(horizontal = itemHPadding, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), + uiKey: UiKey? = null, content: (@Composable RowScope.() -> Unit) ) { val modifier = Modifier + .ui(uiKey) .fillMaxWidth() .sizeIn(minHeight = minHeight) Row( @@ -227,9 +231,10 @@ fun SectionItemViewWithoutMinPadding( PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding) else PaddingValues(horizontal = itemHPadding), + uiKey: UiKey? = null, content: (@Composable RowScope.() -> Unit) ) { - SectionItemView(click, minHeight, disabled, extraPadding, padding, content) + SectionItemView(click, minHeight, disabled, extraPadding, padding, uiKey = uiKey, content = content) } @Composable @@ -243,9 +248,11 @@ fun SectionItemViewLongClickable( PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL, bottom = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) else PaddingValues(horizontal = itemHPadding, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), + uiKey: UiKey? = null, content: (@Composable RowScope.() -> Unit) ) { val modifier = Modifier + .ui(uiKey) .fillMaxWidth() .sizeIn(minHeight = minHeight) Row( 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 d59fc1fe13..99f94e941c 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 @@ -19,14 +19,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import chat.simplex.common.platform.appPlatform +import chat.simplex.common.ui.UiKey +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.res.* @Composable fun SimpleButton(text: String, icon: Painter, color: Color = MaterialTheme.colors.primary, + uiKey: UiKey? = null, click: () -> Unit) { - SimpleButtonFrame(click) { + SimpleButtonFrame(click, Modifier.ui(uiKey)) { Icon( icon, text, tint = color, modifier = Modifier.padding(end = 8.dp) @@ -73,9 +76,10 @@ fun SimpleButtonIconEnded( style: TextStyle = MaterialTheme.typography.caption, color: Color = MaterialTheme.colors.primary, disabled: Boolean = false, + uiKey: UiKey? = null, click: () -> Unit ) { - SimpleButtonFrame(click, disabled = disabled) { + SimpleButtonFrame(click, Modifier.ui(uiKey), disabled = disabled) { Text(text, style = style, color = color) Icon( icon, text, tint = color, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 81a64cb527..8c0aaeda16 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -10,6 +10,7 @@ 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.UiIdle import chat.simplex.common.ui.theme.ThemeModeOverrides import chat.simplex.common.ui.theme.ThemeOverrides import chat.simplex.common.views.chatlist.connectIfOpenedViaUri @@ -35,19 +36,26 @@ private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCorou fun withApi(action: suspend CoroutineScope.() -> Unit): Job = Exception().let { - CoroutineScope(Dispatchers.Main).launch(block = { wrapWithLogging(action, it) }) + CoroutineScope(Dispatchers.Main).launch(block = { wrapWithLogging(action, it) }).trackedBy("withApi") } fun withBGApi(action: suspend CoroutineScope.() -> Unit): Job = Exception().let { - CoroutineScope(singleThreadDispatcher).launch(block = { wrapWithLogging(action, it) }) + CoroutineScope(singleThreadDispatcher).launch(block = { wrapWithLogging(action, it) }).trackedBy("withBGApi") } -fun withLongRunningApi(slow: Long = Long.MAX_VALUE, action: suspend CoroutineScope.() -> Unit): Job = +fun withLongRunningApi(slow: Long = Long.MAX_VALUE, idle: Boolean = true, action: suspend CoroutineScope.() -> Unit): Job = Exception().let { - CoroutineScope(Dispatchers.Default).launch(block = { wrapWithLogging(action, it, slow = slow) }) + val job = CoroutineScope(Dispatchers.Default).launch(block = { wrapWithLogging(action, it, slow = slow) }) + if (idle) job.trackedBy("withLongRunningApi") else job } +private fun Job.trackedBy(what: String): Job { + val id = UiIdle.start { what } + if (id != 0L) invokeOnCompletion { UiIdle.finish(id) } + return this +} + private suspend fun wrapWithLogging(action: suspend CoroutineScope.() -> Unit, exception: java.lang.Exception, slow: Long = 20_000) = coroutineScope { val start = System.currentTimeMillis() action() 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 e2c89940ab..72d5816d76 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 @@ -18,6 +18,7 @@ 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.* import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers @@ -146,13 +147,14 @@ fun AddGroupLayout( } } Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { - ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) + ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester, uiKey = Ui.NewGroup.Name) } Spacer(Modifier.height(8.dp)) SettingsActionItem( painterResource(MR.images.ic_check), stringResource(MR.strings.create_group_button), + uiKey = Ui.NewGroup.Create, click = { createGroup(incognito.value, GroupProfile( displayName = displayName.value.trim(), 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 bdad9d19c4..00fbf2db3c 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 @@ -28,6 +28,8 @@ 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.views.chat.item.CIFileViewScope import chat.simplex.common.views.chat.topPaddingToContent @@ -184,30 +186,19 @@ private fun ModalData.NewChatSheetLayout( } val actionButtonsOriginal = listOf( - Triple( - painterResource(MR.images.ic_add_link), - stringResource(MR.strings.create_1_time_link), - addContact, - ), - Triple( + NewChatAction(painterResource(MR.images.ic_add_link), stringResource(MR.strings.create_1_time_link), Ui.NewChat.CreateLink, addContact), + NewChatAction( painterResource(MR.images.ic_qr_code), if (appPlatform.isAndroid) stringResource(MR.strings.scan_paste_link) else stringResource(MR.strings.paste_link), + null, scanPaste, ), - Triple( - painterResource(MR.images.ic_group), - stringResource(MR.strings.create_group_button), - createGroup, - ), - Triple( - painterResource(MR.images.ic_bigtop_updates), - stringResource(MR.strings.create_channel_button), - createChannel, - ) + 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), ) @Composable - fun DeletedChatsItem(actionButtons: List Unit>>) { + fun DeletedChatsItem(actionButtons: List) { if (searchText.value.text.isEmpty()) { Spacer(Modifier.padding(bottom = 27.dp)) } @@ -217,9 +208,10 @@ private fun ModalData.NewChatSheetLayout( SectionView { actionButtons.map { NewChatButton( - icon = it.first, - text = it.second, - click = it.third, + icon = it.icon, + text = it.text, + click = it.click, + uiKey = it.uiKey, ) } } @@ -454,6 +446,8 @@ private fun ModalData.NewChatSheetLayout( } } +private class NewChatAction(val icon: Painter, val text: String, val uiKey: UiKey?, val click: () -> Unit) + @Composable private fun NewChatButton( icon: Painter, @@ -461,9 +455,10 @@ private fun NewChatButton( click: () -> Unit, textColor: Color = Color.Unspecified, iconColor: Color = MaterialTheme.colors.primary, - disabled: Boolean = false + disabled: Boolean = false, + uiKey: UiKey? = null, ) { - SectionItemView(click, disabled = disabled) { + SectionItemView(click, disabled = disabled, uiKey = uiKey) { Row { Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else iconColor) TextIconSpaced(false) 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 29819f21cb..e60059bc24 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 @@ -36,6 +36,9 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs 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.chat.item.CIFileViewScope import chat.simplex.common.views.chat.topPaddingToContent @@ -231,12 +234,14 @@ private fun ProfilePickerOption( onSelected: () -> Unit, image: @Composable () -> Unit, onInfo: (() -> Unit)? = null, - badge: LocalBadge? = null + badge: LocalBadge? = null, + uiKey: UiKey? = null ) { Row( Modifier .fillMaxWidth() .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) + .ui(uiKey) .clickable(enabled = !disabled, onClick = onSelected) .padding(horizontal = DEFAULT_PADDING, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -401,6 +406,7 @@ fun ActiveProfilePicker( IncognitoOptionImage() }, onInfo = { ModalManager.start.showModal { IncognitoView() } }, + uiKey = Ui.Link.ProfileIncognito ) } @@ -529,6 +535,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact start = 16.dp, end = 16.dp ), + uiKey = Ui.Link.ShareProfile, click = { ModalManager.start.showCustomModal(keyboardCoversBar = false) { close -> val search = rememberSaveable { mutableStateOf("") } @@ -716,7 +723,7 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRC fun LinkTextView(link: String, share: Boolean) { val clipboard = LocalClipboardManager.current Row(Modifier.fillMaxWidth().heightIn(min = DEFAULT_MIN_SECTION_ITEM_HEIGHT).padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.weight(1f).clickable { + Box(Modifier.weight(1f).ui(Ui.Link.Text).clickable { chatModel.markShowingInvitationUsed() clipboard.shareText(link) }) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index 30be129aa8..8255e0db66 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -28,6 +28,8 @@ import chat.simplex.common.BuildConfigCommon 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.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.darkStops @@ -104,6 +106,7 @@ fun OnboardingConditionsView(chatModel: ChatModel) { fontWeight = FontWeight.Medium, color = MaterialTheme.colors.primary, modifier = Modifier + .ui(Ui.Onboarding.ConditionsLink) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null @@ -150,6 +153,7 @@ private fun OnboardingConditionsDesktop(selectedOperatorIds: MutableState Unit) { OnboardingActionButton( - modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + modifier = (if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp)).ui(Ui.Onboarding.OperatorsOk), labelId = MR.strings.ok, onboarding = null, enabled = enabled, @@ -316,7 +322,7 @@ private fun AcceptConditionsButton( selectedOperatorIds: State> ) { OnboardingActionButton( - modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + modifier = (if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp)).ui(Ui.Onboarding.Accept), labelId = MR.strings.onboarding_conditions_accept, onboarding = null, enabled = enabled, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index f198fe1f7a..f812c92de2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.text.style.TextAlign 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.ui.theme.* import chat.simplex.common.views.database.* import chat.simplex.common.views.helpers.* @@ -140,6 +142,7 @@ private fun SetupDatabasePassphraseLayout( newKey, generalGetString(MR.strings.new_passphrase), modifier = Modifier + .ui(Ui.Onboarding.NewPassphrase) .padding(horizontal = DEFAULT_PADDING) .focusRequester(focusRequester) .onPreviewKeyEvent { @@ -160,6 +163,7 @@ private fun SetupDatabasePassphraseLayout( confirmNewKey, generalGetString(MR.strings.confirm_new_passphrase), modifier = Modifier + .ui(Ui.Onboarding.ConfirmPassphrase) .padding(horizontal = DEFAULT_PADDING) .onPreviewKeyEvent { if (!disabled && (it.key == Key.Enter || it.key == Key.NumPadEnter) && it.type == KeyEventType.KeyUp) { @@ -193,7 +197,7 @@ private fun SetupDatabasePassphraseLayout( @Composable private fun SetPassphraseButton(disabled: Boolean, onClick: () -> Unit) { OnboardingActionButton( - if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + (if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp)).ui(Ui.Onboarding.SetPassphrase), labelId = MR.strings.set_database_passphrase, onboarding = null, onclick = onClick, @@ -203,7 +207,7 @@ private fun SetPassphraseButton(disabled: Boolean, onClick: () -> Unit) { @Composable private fun SkipButton(disabled: Boolean, onClick: () -> Unit) { - TextButtonBelowOnboardingButton(stringResource(MR.strings.use_random_passphrase), onClick = if (disabled) null else onClick) + TextButtonBelowOnboardingButton(stringResource(MR.strings.use_random_passphrase), onClick = if (disabled) null else onClick, uiKey = Ui.Onboarding.RandomPassphrase) } @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 41229037b5..fe4f2b4670 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -28,6 +28,9 @@ import chat.simplex.common.BuildConfigCommon 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.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.darkStops @@ -86,7 +89,7 @@ private fun SimpleXInfoDesktop(chatModel: ChatModel) { Spacer(Modifier.fillMaxHeight().weight(1f)) Column(Modifier.widthIn(max = 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingActionButton(user, onboardingStage) - TextButtonBelowOnboardingButton(stringResource(MR.strings.why_simplex_is_built), icon = painterResource(MR.images.ic_info), onClick = { + TextButtonBelowOnboardingButton(stringResource(MR.strings.why_simplex_is_built), icon = painterResource(MR.images.ic_info), uiKey = Ui.Onboarding.WhyBuilt, onClick = { ModalManager.fullscreen.showModal(forceAnimated = true) { HowItWorks(user, onboardingStage) } }) } @@ -154,7 +157,7 @@ fun SimpleXInfoLayout( if (onboardingStage != null) { Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingActionButton(user, onboardingStage) - TextButtonBelowOnboardingButton(stringResource(MR.strings.why_simplex_is_built), icon = painterResource(MR.images.ic_info), onClick = { + TextButtonBelowOnboardingButton(stringResource(MR.strings.why_simplex_is_built), icon = painterResource(MR.images.ic_info), uiKey = Ui.Onboarding.WhyBuilt, onClick = { ModalManager.fullscreen.showModal { HowItWorks(user, onboardingStage) } }) } @@ -220,13 +223,13 @@ fun OnboardingActionButton( } @Composable -fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?, icon: Painter? = null) { +fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?, icon: Painter? = null, uiKey: UiKey? = null) { val state = getKeyboardState() val enabled = onClick != null val topPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else 7.5.dp) val bottomPadding by animateDpAsState(if (appPlatform.isAndroid && state.value == KeyboardState.Opened) 0.dp else 7.5.dp) if ((appPlatform.isAndroid && state.value == KeyboardState.Closed) || topPadding > 0.dp) { - TextButton({ onClick?.invoke() }, Modifier.padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = enabled) { + TextButton({ onClick?.invoke() }, Modifier.ui(uiKey).padding(top = topPadding, bottom = bottomPadding).clip(CircleShape), enabled = enabled) { if (icon != null) { Icon(icon, null, tint = MaterialTheme.colors.primary) Spacer(Modifier.width(4.dp)) @@ -249,9 +252,11 @@ fun TextButtonBelowOnboardingButton(text: String, onClick: (() -> Unit)?, icon: fun OnboardingInformationButton( text: String, onClick: () -> Unit, + uiKey: UiKey? = null, ) { Box( modifier = Modifier + .ui(uiKey) .clip(CircleShape) .clickable { onClick() } ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 81ea810acb..6f7ccb31a5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -53,6 +53,8 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.setConditionsNotified import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo 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.CIFileViewScope import chat.simplex.common.views.helpers.* @@ -199,6 +201,7 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool stringResource(MR.strings.view_updated_conditions), color = MaterialTheme.colors.primary, modifier = Modifier + .ui(Ui.WhatsNew.ViewConditions) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null @@ -225,6 +228,7 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier + .ui(Ui.WhatsNew.Ok) .clickable { close() } .padding(8.dp) ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt index 45f7927c8b..125fab7b86 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/YourNetwork.kt @@ -24,6 +24,8 @@ import chat.simplex.common.BuildConfigCommon 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.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.darkStops @@ -116,7 +118,7 @@ fun YourNetworkView(chatModel: ChatModel) { horizontalAlignment = Alignment.CenterHorizontally ) { OnboardingActionButton( - modifier = Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth(), + modifier = Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth().ui(Ui.Onboarding.Continue), labelId = MR.strings.onboarding_network_operators_continue, onboarding = null, onclick = { @@ -155,7 +157,7 @@ private fun YourNetworkDesktop( Spacer(Modifier.fillMaxHeight().weight(1f)) Column(Modifier.widthIn(max = 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingActionButton( - Modifier.widthIn(min = 300.dp), + Modifier.widthIn(min = 300.dp).ui(Ui.Onboarding.Continue), labelId = MR.strings.onboarding_network_operators_continue, onboarding = null, onclick = { @@ -173,6 +175,7 @@ private fun YourNetworkDesktop( private fun ConfigureRoutersButton(serverOperators: State>, selectedOperatorIds: State>, onClick: () -> Unit) { Box( modifier = Modifier + .ui(Ui.Onboarding.SetupRouters) .clip(CircleShape) .clickable { onClick() } ) { @@ -206,6 +209,7 @@ private fun ConfigureNotificationsButton(notificationMode: State, enabled: Sta fun SharedPreferenceToggle( preference: SharedPreference, enabled: Boolean = true, + uiKey: UiKey? = null, onChange: ((Boolean) -> Unit)? = null, ) { DefaultSwitch( + modifier = Modifier.ui(uiKey), enabled = enabled, checked = remember { preference.state }.value, onCheckedChange = { 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 bd929bc2f7..9bdf240c1f 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 @@ -25,6 +25,8 @@ 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.views.badges.BadgesView import chat.simplex.common.views.database.DatabaseView @@ -99,9 +101,9 @@ fun SettingsLayout( AppBarTitle(stringResource(MR.strings.your_settings)) SectionView { - SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) }) + SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) }, uiKey = Ui.Settings.Appearance) SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.your_privacy), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped) - SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.help_and_support), showSettingsModal { HelpAndSupportView(it, showModal, showCustomModal) }) + 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) } @@ -157,7 +159,7 @@ fun HelpAndSupportView( 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_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close = close) }, disabled = stopped) + 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) }) } SectionDividerSpaced() @@ -381,8 +383,8 @@ fun AppVersionItem(showVersion: () -> Unit) { } @Composable -fun SettingsActionItem(icon: Painter, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false) { - SectionItemView(click, disabled = disabled, extraPadding = extraPadding) { +fun SettingsActionItem(icon: Painter, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, iconColor: Color = MaterialTheme.colors.secondary, disabled: Boolean = false, extraPadding: Boolean = false, uiKey: UiKey? = null) { + SectionItemView(click, disabled = disabled, extraPadding = extraPadding, uiKey = uiKey) { Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else iconColor) TextIconSpaced(extraPadding) Text(text, color = if (disabled) MaterialTheme.colors.secondary else textColor) @@ -426,10 +428,11 @@ fun SettingsPreferenceItem( pref: SharedPreference, iconColor: Color = MaterialTheme.colors.secondary, enabled: Boolean = true, + uiKey: UiKey? = null, onChange: ((Boolean) -> Unit)? = null, ) { SettingsActionItemWithContent(icon, text, iconColor = iconColor,) { - SharedPreferenceToggle(pref, enabled, onChange) + SharedPreferenceToggle(pref, enabled, uiKey, onChange) } } 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 826648ddb8..b065f9c736 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 @@ -25,6 +25,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.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 @@ -433,6 +434,7 @@ private fun CreateAddressButton(onClick: () -> Unit) { onClick, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, + uiKey = Ui.Address.Create, ) } 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 cc1149e44d..d7234ec04b 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 @@ -18,6 +18,8 @@ import androidx.compose.ui.text.style.TextAlign 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.helpers.* import chat.simplex.common.views.onboarding.ReadableText @@ -197,7 +199,7 @@ fun UserProfileLayout( } } } - ProfileNameField(displayName, "", { isValidNewProfileName(it, profile) }, focusRequester) + ProfileNameField(displayName, "", { isValidNewProfileName(it, profile) }, focusRequester, uiKey = Ui.Profile.Name) if (showFullName(profile)) { Spacer(Modifier.height(DEFAULT_PADDING)) Text( @@ -236,7 +238,7 @@ fun UserProfileLayout( Spacer(Modifier.height(DEFAULT_PADDING)) val enabled = !dataUnchanged() && canSaveProfile(displayName.value, shortDescr.value, profile) - val saveModifier: Modifier = Modifier.clickable(enabled) { saveProfile(displayName.value, fullName.value, shortDescr.value, description.value, profileImage.value) } + val saveModifier: Modifier = Modifier.ui(Ui.Profile.Save).clickable(enabled) { saveProfile(displayName.value, fullName.value, shortDescr.value, description.value, profileImage.value) } val saveColor: Color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Text( stringResource(MR.strings.save_and_notify_contacts), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index e91d6c2ce0..eec6264a34 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 @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp 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.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.UserProfilePickerItem @@ -179,7 +180,7 @@ private fun UserProfilesLayout( Divider(Modifier.padding(horizontal = 8.dp)) } if (searchTextOrPassword.value.trim().isEmpty()) { - SectionItemView(addUser, minHeight = 68.dp) { + SectionItemView(addUser, minHeight = 68.dp, uiKey = Ui.Profiles.Add) { Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.users_add), tint = MaterialTheme.colors.primary) Spacer(Modifier.padding(horizontal = 4.dp)) Text(stringResource(MR.strings.users_add), color = MaterialTheme.colors.primary) 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 92714b5910..8c9baf3fec 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 @@ -38,6 +38,8 @@ import chat.simplex.common.model.ChatController.getUserServers 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.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingActionButton @@ -839,7 +841,7 @@ fun SimpleConditionsView( } Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).padding(bottom = DEFAULT_PADDING * 2).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingActionButton( - modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + modifier = (if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp)).ui(Ui.Onboarding.ConditionsAccept), labelId = MR.strings.onboarding_conditions_accept, onboarding = null, onclick = onAccept 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 3e048a93d5..b2e6fc9b21 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,6 +20,8 @@ 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.ui import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* import chat.simplex.res.* @@ -116,6 +118,7 @@ actual fun PlatformTextField( autoCorrectEnabled = true ), modifier = Modifier + .ui(Ui.Chat.Message) .padding(start = startPadding, end = endPadding) .offset(y = (-5).dp) .fillMaxWidth() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index c875c0c9da..89fc1756db 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.unit.dp import chat.simplex.common.platform.onRightClick +import chat.simplex.common.ui.UiKey +import chat.simplex.common.ui.ui import chat.simplex.common.views.helpers.* object NoIndication : IndicationNodeFactory { @@ -33,8 +35,9 @@ actual fun ChatListNavLinkLayout( disabled: Boolean, selectedChat: State, nextChatSelected: State, + uiKey: UiKey?, ) { - var modifier = Modifier.fillMaxWidth() + var modifier = Modifier.fillMaxWidth().ui(uiKey) if (!disabled) modifier = modifier .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) .onRightClick { showMenu.value = true } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt index dba5ddb9e6..bfb3badbfb 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/TagListView.desktop.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.ui.Ui +import chat.simplex.common.ui.ui import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.views.chat.item.isHeartEmoji @@ -55,6 +57,7 @@ private fun SingleEmojiInput( }, singleLine = true, modifier = Modifier + .ui(Ui.TagEditor.Emoji) .padding(4.dp) .size(width = TextFieldDefaults.MinHeight.value.sp.toDp(), height = TextFieldDefaults.MinHeight), textStyle = LocalTextStyle.current.copy(fontFamily = EmojiFont, textAlign = TextAlign.Center), diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt index 43428bab72..d58d3b8742 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.desktop.kt @@ -19,6 +19,8 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.User import chat.simplex.common.model.UserInfo 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.helpers.* import kotlinx.coroutines.flow.MutableStateFlow @@ -51,6 +53,7 @@ actual fun UserPickerUsersSection( ) { row.forEach { u -> Column(modifier = Modifier + .ui(Ui.UserPicker.User(u.user.userId)) .clickable ( onClick = { onUserClicked(u.user) }, enabled = !stopped @@ -96,6 +99,7 @@ actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow< Box( Modifier .fillMaxSize() + .ui(Ui.UserPicker.Sheet) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING }), contentAlignment = Alignment.TopStart ) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt index 306562e6ad..16d1c0428b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -168,7 +168,7 @@ fun setupUpdateChecker() = withLongRunningApi { } private fun createUpdateJob() { - updateCheckerJob = withLongRunningApi { + updateCheckerJob = withLongRunningApi(idle = false) { delay(24 * 60 * 60 * 1000) checkForUpdate() createUpdateJob() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt index 30e1020d48..803e0669d8 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.desktop.kt @@ -8,6 +8,8 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.SharedPreference import chat.simplex.common.model.User 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 import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource @@ -16,8 +18,8 @@ import dev.icerock.moko.resources.compose.painterResource actual fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference, onclick: (() -> Unit)?) { if (user == null) { Row(horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING * 2.5f)) { - OnboardingActionButton(labelId = MR.strings.link_a_mobile, onboarding = if (controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) OnboardingStage.Step2_5_SetupDatabasePassphrase else OnboardingStage.LinkAMobile, icon = painterResource(MR.images.ic_smartphone_300), onclick = onclick) - OnboardingActionButton(labelId = MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, icon = painterResource(MR.images.ic_desktop), onclick = onclick) + OnboardingActionButton(Modifier.ui(Ui.Onboarding.LinkAMobile), labelId = MR.strings.link_a_mobile, onboarding = if (controller.appPrefs.initialRandomDBPassphrase.get() && !chatModel.desktopOnboardingRandomPassword.value) OnboardingStage.Step2_5_SetupDatabasePassphrase else OnboardingStage.LinkAMobile, icon = painterResource(MR.images.ic_smartphone_300), onclick = onclick) + OnboardingActionButton(Modifier.ui(Ui.Onboarding.GetStarted), labelId = MR.strings.create_your_profile, onboarding = OnboardingStage.Step2_CreateProfile, icon = painterResource(MR.images.ic_desktop), onclick = onclick) } } else { OnboardingActionButton(Modifier.widthIn(min = 300.dp), labelId = MR.strings.make_private_connection, onboarding = OnboardingStage.OnboardingComplete, onclick = onclick) 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 7a48f37271..aa70b3450f 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 @@ -24,6 +24,8 @@ import chat.simplex.common.model.CloseBehavior import chat.simplex.common.model.SharedPreference import chat.simplex.common.trayIsAvailable 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.helpers.* import chat.simplex.res.* @@ -134,6 +136,7 @@ fun DensityScaleSection() { Spacer(Modifier.width(15.dp)) Slider( localDensityScale.value, + modifier = Modifier.ui(Ui.Appearance.Zoom), valueRange = 1f..2f, steps = 11, onValueChange = { diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ChatListFiltersListsDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ChatListFiltersListsDesktopTest.kt new file mode 100644 index 0000000000..eeadc55124 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/ChatListFiltersListsDesktopTest.kt @@ -0,0 +1,46 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.lists +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ChatListFiltersListsDesktopTest : UiTest() { + @Test fun createListWithEmoji() = uiTest { + val alice = app("alice") + alice.chatList.row("Private notes") + val lists = lists() + lists.addList().apply { + typeInto(Ui.TagEditor.Emoji, "🚀") + typeName("work") + save() + } + val work = lists.tag("work") + check(work.chatTagEmoji == "🚀") { "list created with emoji ${work.chatTagEmoji}" } + poll(what = "the work chip with the emoji") { lists.chipTexts("work").containsAll(listOf("🚀", "work")) } + } + + @Test fun editListNameAndEmoji() = uiTest { + val alice = app("alice") + alice.chatList.row("Private notes") + val lists = lists() + lists.addList().apply { + typeInto(Ui.TagEditor.Emoji, "🚀") + typeName("work") + save() + } + val id = lists.tag("work").chatTagId + lists.menu("work", "edit") + val editor = lists.editor() + poll(what = "the editor with the list's emoji and name") { editor.emojiText() == "🚀" && editor.nameText() == "work" && editor.saveLabel() == "Save list" } + editor.saveIgnored() + replaceText(Ui.TagEditor.Emoji, "🏠") + editor.replaceName("home") + editor.save() + poll(what = "the chip with the new emoji and name") { + chatModel.userTags.value.single { it.chatTagId == id }.let { it.chatTagText == "home" && it.chatTagEmoji == "🏠" } && + textsOf(Ui.ChatList.UserTag(id)).containsAll(listOf("🏠", "home")) && "work" !in textsOf(Ui.ChatList.UserTag(id)) + } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/OnboardingTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/OnboardingTest.kt new file mode 100644 index 0000000000..4653bdc5c0 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/OnboardingTest.kt @@ -0,0 +1,302 @@ +package chat.simplex.app.ui.desktop + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.performTextInput +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +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.onboarding +import chat.simplex.app.ui.harness.quiesce +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatInfo +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.DBMigrationResult +import chat.simplex.common.views.helpers.ModalManager +import chat.simplex.common.views.migration.MigrationToState +import chat.simplex.common.views.onboarding.OnboardingSharedState +import chat.simplex.common.views.onboarding.OnboardingStage +import org.junit.Test + +class OnboardingTest : UiTest() { + @Test fun onboardingWelcomeShowsDesktopActions() = uiTest { + app(null) + poll(what = "the welcome screen") { onboarding().stage == OnboardingStage.Step1_SimpleXInfo && exists(Ui.Onboarding.GetStarted) } + check(chatModel.currentUser.value == null) { "a user exists on the welcome screen" } + check("Link a mobile" in textsOf(Ui.Onboarding.LinkAMobile)) { "Link a mobile: ${textsOf(Ui.Onboarding.LinkAMobile)}" } + check("Create your profile" in textsOf(Ui.Onboarding.GetStarted)) { "Create your profile: ${textsOf(Ui.Onboarding.GetStarted)}" } + check("Why SimpleX is built." in textsOf(Ui.Onboarding.WhyBuilt)) { "Why SimpleX is built.: ${textsOf(Ui.Onboarding.WhyBuilt)}" } + } + + @Test fun onboardingWhySimplexIsBuiltOpensHowItWorks() = uiTest { + app(null) + waitFor(Ui.Onboarding.WhyBuilt) + check(!seesText(WHY_BUILT_HEADING)) { "the welcome screen shows the HowItWorks heading" } + click(Ui.Onboarding.WhyBuilt) + poll(what = "HowItWorks over the welcome screen") { ModalManager.fullscreen.hasModalsOpen() && seesText(WHY_BUILT_HEADING) } + } + + @Test fun onboardingCreateProfileValidName() = uiTest { + app(null) + onboarding().createProfile("alice") + poll(what = "the passphrase step") { + onboarding().stage == OnboardingStage.Step2_5_SetupDatabasePassphrase && seesText("Setup database passphrase") && exists(Ui.Onboarding.SetPassphrase) + } + } + + @Test fun onboardingCreateProfileButtonDisabledForEmptyName() = uiTest { + app(null) + click(Ui.Onboarding.GetStarted) + waitFor(Ui.Onboarding.CreateProfile) + settle() + check(!isEnabled(Ui.Onboarding.CreateProfile)) { "Create profile is enabled with an empty name" } + typeInto(Ui.Onboarding.ProfileName, " ") + settle() + check(!isEnabled(Ui.Onboarding.CreateProfile)) { "Create profile is enabled with a blank name" } + check(chatModel.currentUser.value == null) { "a profile was created" } + } + + @Test fun onboardingCreateProfileInvalidNameShowsCorrectionAlert() = uiTest { + app(null) + click(Ui.Onboarding.GetStarted) + typeInto(Ui.Onboarding.ProfileName, "alice_1") + waitFor(Ui.Onboarding.ProfileName.part("invalid")) + waitFor(Ui.Onboarding.CreateProfile) + check(!isEnabled(Ui.Onboarding.CreateProfile)) { "Create profile is enabled for an invalid name" } + click(Ui.Onboarding.ProfileName.part("invalid")) + poll(what = "the correction alert") { seesText("Invalid name!") && seesText("Correct name to alice-1?") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the corrected name") { + editableText(Ui.Onboarding.ProfileName) == "alice-1" && isEnabled(Ui.Onboarding.CreateProfile) && !exists(Ui.Onboarding.ProfileName.part("invalid")) + } + } + + @Test fun onboardingCreateProfileBackReturnsToWelcome() = uiTest { + app(null) + click(Ui.Onboarding.GetStarted) + waitFor(Ui.Onboarding.ProfileName) + escape() + poll(what = "the welcome screen") { + onboarding().stage == OnboardingStage.Step1_SimpleXInfo && exists(Ui.Onboarding.LinkAMobile) && !exists(Ui.Onboarding.ProfileName) + } + check(chatModel.currentUser.value == null) { "a profile was created" } + } + + @Test fun onboardingMigrateFromAnotherDeviceOpensMigrationScreen() = uiTest { + app(null) + click(Ui.Onboarding.GetStarted) + check("Migrate from another device" in textsOf(Ui.Onboarding.Migrate)) { "migrate button: ${textsOf(Ui.Onboarding.Migrate)}" } + click(Ui.Onboarding.Migrate) + poll(what = "Migrate here in the paste-link state") { + chatModel.migrationState.value is MigrationToState.PasteOrScanLink && seesText("Migrate here") && seesText("Paste archive link") + } + } + + @Test fun onboardingSetDatabasePassphrase() = uiTest { + app(null) + onboarding().createProfile("alice") + typeSecret(Ui.Onboarding.NewPassphrase, PASSPHRASE) + typeSecret(Ui.Onboarding.ConfirmPassphrase, PASSPHRASE) + click(Ui.Onboarding.SetPassphrase) + poll(what = "the Encrypt database? alert") { seesText("Encrypt database?") && seesText("Database will be encrypted.", substring = true) } + check("Encrypt" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared))) { "confirm: ${textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared))}" } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = 30_000, what = "Your network and the Database encrypted! alert") { + onboarding().stage == OnboardingStage.Step3_ChooseServerOperators && seesText("Database encrypted!") + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "Your network") { !AlertManager.shared.hasAlertsShown() && exists(Ui.Onboarding.Continue) } + check(storedSetting("StoreDBPassphrase") == "false") { "StoreDBPassphrase: ${storedSetting("StoreDBPassphrase")}" } + } + + @Test fun onboardingSetPassphraseMismatchDisablesButton() = uiTest { + app(null) + onboarding().createProfile("alice") + typeSecret(Ui.Onboarding.NewPassphrase, PASSPHRASE) + typeSecret(Ui.Onboarding.ConfirmPassphrase, PASSPHRASE.dropLast(1)) + settle() + waitFor(Ui.Onboarding.SetPassphrase) + check(!isEnabled(Ui.Onboarding.SetPassphrase)) { "Set database passphrase is enabled for mismatched passphrases" } + typeSecret(Ui.Onboarding.ConfirmPassphrase, PASSPHRASE.takeLast(1)) + poll(what = "Set database passphrase to be enabled for matching passphrases") { isEnabled(Ui.Onboarding.SetPassphrase) } + } + + @Test fun onboardingUseRandomPassphraseAlert() = uiTest { + app(null) + onboarding().createProfile("alice") + click(Ui.Onboarding.RandomPassphrase) + poll(what = "the Use random passphrase alert") { + AlertManager.shared.hasAlertsShown() && seesText("Random passphrase is stored in settings as plaintext.", substring = true) + } + check("OK" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared))) { "confirm: ${textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared))}" } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = 30_000, what = "Your network") { onboarding().stage == OnboardingStage.Step3_ChooseServerOperators && exists(Ui.Onboarding.Continue) } + check(appPrefs.storeDBPassphrase.get() && storedSetting("EncryptedDBPassphrase") != null) { "the random passphrase is not stored in settings" } + } + + @Test fun onboardingPassphrasePersistsAcrossRestart() = uiTest { + app(null) + onboarding().createProfile("alice") + typeSecret(Ui.Onboarding.NewPassphrase, PASSPHRASE) + typeSecret(Ui.Onboarding.ConfirmPassphrase, PASSPHRASE) + click(Ui.Onboarding.SetPassphrase) + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(timeoutMs = 30_000, what = "the Database encrypted! alert") { seesText("Database encrypted!") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + check(storedSetting("StoreDBPassphrase") == "false" && storedSetting("EncryptedDBPassphrase") == null) { "a passphrase is stored in settings" } + restart() + poll(timeoutMs = 30_000, what = "the passphrase prompt") { + chatModel.chatDbStatus.value is DBMigrationResult.ErrorNotADatabase && seesText("Encrypted database") && seesText("Database passphrase is required to open chat.") + } + } + + @Test fun onboardingYourNetworkShowsSetupRouters() = uiTest { + app(null) + onboarding().toYourNetwork("alice") + poll(what = "the Your network title and subtitle") { seesText("Your network") && seesText("Network routers cannot know", substring = true) } + check("Setup routers" in textsOf(Ui.Onboarding.SetupRouters)) { "routers button: ${textsOf(Ui.Onboarding.SetupRouters)}" } + onboarding().openOperators() + poll(what = "Server operators with a card per operator") { + seesText("Server operators") && chatModel.conditions.value.serverOperators.all { exists(Ui.Onboarding.Operator(it.operatorId)) } + } + } + + @Test fun onboardingContinueGoesToConditions() = uiTest { + app(null) + onboarding().toYourNetwork("alice") + click(Ui.Onboarding.Continue) + poll(what = "Network commitments") { + onboarding().stage == OnboardingStage.Step4_NetworkCommitments && seesText("Network commitments") && exists(Ui.Onboarding.ConditionsLink) && isEnabled(Ui.Onboarding.Accept) + } + } + + @Test fun onboardingChooseOperatorsToggle() = uiTest { + app(null) + val o = onboarding() + o.toYourNetwork("alice") + o.openOperators() + val id = chatModel.conditions.value.serverOperators.first().operatorId + val before = id in OnboardingSharedState.selectedOperatorIds + click(Ui.Onboarding.Operator(id)) + poll(what = "operator $id to toggle") { (id in OnboardingSharedState.selectedOperatorIds) == !before } + click(Ui.Onboarding.Operator(id)) + poll(what = "operator $id to toggle back") { (id in OnboardingSharedState.selectedOperatorIds) == before } + } + + @Test fun onboardingChooseOperatorsCannotDeselectAll() = uiTest { + app(null) + val o = onboarding() + o.toYourNetwork("alice") + o.openOperators() + val enabled = o.enabledOperatorIds() + check(enabled.isNotEmpty()) { "no operator is enabled in a new database" } + for (id in enabled) click(Ui.Onboarding.Operator(id)) + poll(what = "no operator selected and OK disabled") { OnboardingSharedState.selectedOperatorIds.isEmpty() && exists(Ui.Onboarding.OperatorsOk) && !isEnabled(Ui.Onboarding.OperatorsOk) } + escape() + settle() + check(exists(Ui.Onboarding.OperatorsOk) && ModalManager.fullscreen.hasModalsOpen()) { "Server operators closed with no operator selected" } + click(Ui.Onboarding.Operator(enabled.first())) + click(Ui.Onboarding.OperatorsOk) + poll(what = "Server operators to close") { !exists(Ui.Onboarding.OperatorsOk) && OnboardingSharedState.selectedOperatorIds == setOf(enabled.first()) } + } + + @Test fun onboardingHowItHelpsPrivacyInfo() = uiTest { + app(null) + val o = onboarding() + o.toYourNetwork("alice") + o.openOperators() + check(!seesText("Network operators")) { "Network operators is shown before the info button" } + val modals = ModalManager.fullscreen.openModalCount() + click(Ui.Onboarding.HowItHelps) + poll(what = "the Network operators info screen") { ModalManager.fullscreen.openModalCount() == modals + 1 && seesText("Network operators") } + } + + @Test fun onboardingAcceptConditionsCompletesOnboarding() = uiTest { + app(null) + val o = onboarding() + o.toConditions("alice") + val selected = OnboardingSharedState.selectedOperatorIds + click(Ui.Onboarding.Accept) + o.waitForChatList() + poll(what = "the connect onboarding view and the selected operators enabled") { seesText("Talk to someone") && o.enabledOperatorIds() == selected } + } + + @Test fun onboardingConditionsLinkOpensConditionsText() = uiTest { + app(null) + val o = onboarding() + o.toConditions("alice") + click(Ui.Onboarding.ConditionsLink) + poll(what = "Conditions of use") { seesText("Conditions of use") && exists(Ui.Onboarding.ConditionsAccept) } + click(Ui.Onboarding.ConditionsAccept) + o.waitForChatList() + poll(what = "the connect onboarding view") { seesText("Talk to someone") } + } + + @Test fun onboardingNoNotificationsStepOnDesktop() = uiTest { + app(null) + onboarding().toYourNetwork("alice") + waitFor(Ui.Onboarding.SetupRouters) + settle() + check(!exists(Ui.Onboarding.SetupNotifications) && !seesText("Setup notifications")) { "Setup notifications is shown on desktop" } + } + + @Test fun onboardingThenConnectToCliPeerViaLocalServers() = uiTest { + val alice = app(null) + onboarding().complete("alice") + quiesce() + TestServers.configure() + val link = alice.newChat().createOneTimeLink() + val bob = cli("bob") + bob sends "/c $link" + bob expects "confirmation sent!" + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "contact bob ready in the model") { + chatModel.chats.value.any { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.displayName == "bob" && c.ready } == true } + } + } + + @Test fun onboardingLinkAMobileShowsQrAndDeviceName() = uiTest { + app(null) + appPrefs.developerTools.set(true) + appPrefs.offerRemoteMulticast.set(false) + click(Ui.Onboarding.LinkAMobile) + onboarding().useRandomPassphrase() + poll(timeoutMs = 30_000, what = "the Link a mobile screen with a QR code") { onboarding().stage == OnboardingStage.LinkAMobile && seesDescription("QR Code") } + check(seesText("This device name") && seesText("Discoverable via local network")) { "device name section: ${texts().take(40)}" } + check(editableText(Ui.Remote.DeviceName) == appPrefs.deviceNameForRemoteAccess.get()) { "device name field: ${editableText(Ui.Remote.DeviceName)}" } + click(Ui.Remote.ShareLink) + poll(what = "the invitation on the clipboard") { clipboardText()?.startsWith("xrcp:/") == true } + } + + @Test fun onboardingLinkAMobileBackReturnsToWelcome() = uiTest { + app(null) + appPrefs.offerRemoteMulticast.set(false) + click(Ui.Onboarding.LinkAMobile) + onboarding().useRandomPassphrase() + poll(timeoutMs = 30_000, what = "the Link a mobile screen") { onboarding().stage == OnboardingStage.LinkAMobile && exists(Ui.Remote.DeviceName) } + escape() + poll(what = "the welcome screen") { + onboarding().stage == OnboardingStage.Step1_SimpleXInfo && exists(Ui.Onboarding.LinkAMobile) && !exists(Ui.Remote.DeviceName) + } + check(chatModel.currentUser.value == null) { "a profile was created" } + } + + private fun UiScope.typeSecret(key: UiKey, text: String) { + waitFor(key) + val before = secretLength(key) + val node = ui.onNode(matcher(key)) + ui.runOnUiThread { node.performTextInput(text) } + poll(what = "field ${key.tag} to hold ${before + text.length} characters") { secretLength(key) == before + text.length } + } + + private fun UiScope.secretLength(key: UiKey): Int = + ui.onNode(matcher(key)).fetchSemanticsNode().config.getOrElseNullable(SemanticsProperties.EditableText) { null }?.length ?: 0 + + private companion object { + const val WHY_BUILT_HEADING = "You were born without an account." + const val PASSPHRASE = "Uitest-passphrase-7" + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/WhatsNewDesktopTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/WhatsNewDesktopTest.kt new file mode 100644 index 0000000000..92d067466e --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/desktop/WhatsNewDesktopTest.kt @@ -0,0 +1,38 @@ +package chat.simplex.app.ui.desktop + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.UiScope +import chat.simplex.app.ui.harness.WhatsNewScreen +import chat.simplex.app.ui.harness.onboarding +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.onboarding.OnboardingStage +import org.junit.Test + +class WhatsNewDesktopTest : UiTest() { + @Test fun whatsNewNotShownForNewUser() = uiTest { + app(null) + check(storedSetting("WhatsNewVersion") == null) { "a What's New version is stored before onboarding" } + click(Ui.Onboarding.GetStarted) + typeInto(Ui.Onboarding.ProfileName, "alice") + poll(what = "the stored What's New version to be the current one on the profile screen") { storedSetting("WhatsNewVersion") == WhatsNewScreen.VERSION } + finishOnboarding() + repeat(NO_MODAL_SETTLES) { settle() } + check(!WhatsNewScreen(this).shown() && !exists(Ui.WhatsNew.Ok)) { "What's New opened for a new user" } + } + + private fun UiScope.finishOnboarding() { + poll(what = "Create profile to be enabled") { isEnabled(Ui.Onboarding.CreateProfile) } + click(Ui.Onboarding.CreateProfile) + onboarding().useRandomPassphrase() + click(Ui.Onboarding.Continue) + click(Ui.Onboarding.Accept) + poll(timeoutMs = 30_000, what = "the chat list after onboarding") { + appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && exists(Ui.ChatList.UserPicker) + } + } + + private companion object { + const val NO_MODAL_SETTLES = 5 + } +} 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 new file mode 100644 index 0000000000..9c21119b94 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/CliPeer.kt @@ -0,0 +1,133 @@ +package chat.simplex.app.ui.harness + +import com.pty4j.PtyProcess +import com.pty4j.PtyProcessBuilder +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) { + private val transcript = File(dir, "transcript.log") + private lateinit var screen: VtScreen + private var proc: PtyProcess? = null + private var reader: Thread? = null + + fun start() { + File(dir, "tmp").mkdirs() + 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) + if (flags.reactions) args += "--reactions" + if (flags.markRead) args += "--mark-read" + if (flags.fullLinks) args += "--show-full-links" + 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) + put("LC_ALL", "C.UTF-8"); put("TERM", "xterm-256color") + } + synchronized(lines) { lines.clear() } + screen = VtScreen(COLUMNS, ROWS) { line -> + transcript.appendText("${System.currentTimeMillis() % 100_000_000} $line\n") + synchronized(lines) { lines.add(line) } + } + val p = PtyProcessBuilder(cmd).setEnvironment(env).setDirectory(UiEnv.repoRoot.absolutePath) + .setInitialColumns(COLUMNS).setInitialRows(ROWS).setConsole(false).start() + proc = p + reader = Thread({ + val r = InputStreamReader(p.inputStream, Charsets.UTF_8) + val buf = CharArray(8192) + try { + while (true) { val n = r.read(buf); if (n < 0) break; screen.feed(String(buf, 0, n)) } + } catch (_: Exception) {} + }, "cli-$name").apply { isDaemon = true; start() } + started = false + startDeadline = System.currentTimeMillis() + START_TIMEOUT_MS + } + + private var started = true + private var startDeadline = 0L + + fun awaitStart() = ensureStarted() + + fun drainLines(): List = synchronized(lines) { lines.toList().also { lines.clear() } } + + private fun ensureStarted() { + if (started) return + waiter.waitUntil((startDeadline - System.currentTimeMillis()).coerceAtLeast(0), "$name to start") { + synchronized(lines) { lines.any { it.startsWith("Current user: ") } } && promptShown() + } + synchronized(lines) { lines.clear() } + started = true + } + + fun stop() { + val p = proc ?: return + proc = null + p.destroy() + if (!p.waitFor(5, TimeUnit.SECONDS)) { p.destroyForcibly(); p.waitFor(5, TimeUnit.SECONDS) } + runCatching { p.inputStream.close() }; runCatching { p.outputStream.close() }; runCatching { p.errorStream.close() } + reader?.join(2000) + } + + override fun fill() = ensureStarted() + + override fun type(text: String) { + ensureStarted() + typeVerified(text) + enter(text) + } + + private fun inputLine(): String = screen.cursorRowText().let { if (it.startsWith(PROMPT)) it.removePrefix(PROMPT) else it.removePrefix(">") } + + private fun promptShown(): Boolean = screen.cursorRowText().startsWith(">") + + private fun write(s: String) { + val out = proc!!.outputStream + out.write(s.toByteArray(Charsets.UTF_8)); out.flush() + } + + private fun typeVerified(text: String) { + var seen = "" + repeat(TYPE_ATTEMPTS) { + write(text) + if (settles { inputLine() == text }) return + seen = inputLine() + write("\u007f".repeat(seen.length + 2)) + settles { inputLine().isEmpty() } + } + throw AssertionError("$name: could not type `$text`; the input line held `$seen`") + } + + private fun enter(text: String) { + write("\r") + val retryAt = System.currentTimeMillis() + ENTER_RETRY_MS + while (System.currentTimeMillis() < retryAt) { + if (inputLine() != text) return + Thread.sleep(10) + } + write("\r") + } + + private fun settles(done: () -> Boolean): Boolean { + var last = inputLine() + var changedAt = System.currentTimeMillis() + while (true) { + if (done()) return true + Thread.sleep(10) + val now = inputLine() + if (now != last) { last = now; changedAt = System.currentTimeMillis() } + else if (System.currentTimeMillis() - changedAt > UNCHANGED_MS) return done() + } + } + + companion object { + const val COLUMNS = 4000 + const val ROWS = 50 + private const val PROMPT = "> " + private const val START_TIMEOUT_MS = 60_000L + private const val TYPE_ATTEMPTS = 5 + private const val UNCHANGED_MS = 1_500L + private const val ENTER_RETRY_MS = 500L + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/DbTemplate.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/DbTemplate.kt new file mode 100644 index 0000000000..382a8b9231 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/DbTemplate.kt @@ -0,0 +1,43 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.DatabaseUtils.ksDatabasePassword +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +object DbTemplate { + private val shared = File(UiEnv.runDir, "db-template") + private val keyFile = File(shared, "key") + + val exists: Boolean get() = keyFile.isFile + + fun installIfPresent() { + if (!exists) return + dbFiles(shared).forEach { it.copyTo(File(dataDir, it.name), overwrite = true) } + ksDatabasePassword.set(keyFile.readText()) + appPrefs.storeDBPassphrase.set(true) + appPrefs.initialRandomDBPassphrase.set(true) + } + + fun save() { + val staging = File(UiEnv.jvmDir, "db-template") + staging.deleteRecursively() + staging.mkdirs() + dbFiles(dataDir).forEach { it.copyTo(File(staging, it.name)) } + File(staging, "key").writeText(ksDatabasePassword.get() ?: error("no database key after initChatController")) + try { + Files.move(staging.toPath(), shared.toPath(), StandardCopyOption.ATOMIC_MOVE) + } catch (e: AtomicMoveNotSupportedException) { + throw IllegalStateException("run directory and JVM directory must be on one filesystem", e) + } catch (e: java.nio.file.FileSystemException) { + if (!exists) throw e + staging.deleteRecursively() + } + } + + private fun dbFiles(dir: File): List = + dir.listFiles { f -> f.isFile && (f.name.startsWith(chatDatabaseFileName) || f.name.startsWith(agentDatabaseFileName)) }.orEmpty().toList() +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessBootTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessBootTest.kt new file mode 100644 index 0000000000..f423753f1a --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessBootTest.kt @@ -0,0 +1,14 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Test + +class HarnessBootTest : UiTest() { + @Test fun bootShowsChatListForNewUser() = uiTest { + app("alice") + waitFor(Ui.ChatList.NewChat) + assertEquals(0, appPrefs.appearanceBarsBlurRadius.get()) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessCliPeerTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessCliPeerTest.kt new file mode 100644 index 0000000000..918f01d2a9 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessCliPeerTest.kt @@ -0,0 +1,17 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.platform.chatController +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class HarnessCliPeerTest : UiTest() { + @Test fun cliConnectsToGuiInvitation() = uiTest { + val alice = app("alice") + val bob = cli("bob") + val (link, _) = runBlocking { chatController.apiAddContact(null, incognito = false) }.first ?: error("apiAddContact failed") + bob sends "/c ${link.simplexChatUri(short = true)}" + bob expects "confirmation sent!" + bob.eventually("alice: contact is connected") + alice.chatList.row("bob") + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessMeasureTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessMeasureTest.kt new file mode 100644 index 0000000000..5029fce4b6 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessMeasureTest.kt @@ -0,0 +1,28 @@ +package chat.simplex.app.ui.harness + +import org.junit.Ignore +import org.junit.Test + +@Ignore("benchmark of reset, frame and capture costs; run manually with --tests '*HarnessMeasureTest*' after removing this line") +class HarnessMeasureTest : UiTest() { + @Test fun measureResetsFramesAndCapture() = uiTest { + val rt = Runtime.getRuntime() + fun usedMb(): Long { System.gc(); Thread.sleep(200); System.gc(); return (rt.totalMemory() - rt.freeMemory()) / 1_048_576 } + fun rssMb(): Long = java.io.File("/proc/self/status").readLines().first { it.startsWith("VmRSS:") }.split(Regex("\\s+"))[1].toLong() / 1024 + val resetMs = mutableListOf() + for (i in 1..20) { + val t = System.nanoTime() + app("alice") + poll(what = "the chat list") { seesText("Private notes") } + resetMs += (System.nanoTime() - t) / 1_000_000 + if (i == 1 || i % 5 == 0) println("UITEST reset#$i ms=${resetMs.last()} heapMb=${usedMb()} rssMb=${rssMb()} threads=${Thread.activeCount()}") + } + println("UITEST reset ms: first=${resetMs.first()} median=${resetMs.drop(1).sorted()[resetMs.size / 2 - 1]} max=${resetMs.drop(1).max()}") + val frames = 120 + val t = System.nanoTime() + repeat(frames) { ui.mainClock.advanceTimeBy(16); ui.waitForIdle() } + println("UITEST frame ms avg=${(System.nanoTime() - t) / 1_000_000.0 / frames}") + val caps = (1..10).map { val c = System.nanoTime(); ui.captureToImage(); (System.nanoTime() - c) / 1_000_000 } + println("UITEST captureToImage ms=${caps}") + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessResetTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessResetTest.kt new file mode 100644 index 0000000000..f59a0cefc2 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/HarnessResetTest.kt @@ -0,0 +1,34 @@ +package chat.simplex.app.ui.harness + +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.ModalManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runners.MethodSorters + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class HarnessResetTest : UiTest() { + @Test fun a_leavesStateBehind() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + appPrefs.densityScale.set(1.7f) + click(Ui.ChatList.NewChat) + poll(what = "the new chat sheet") { ModalManager.start.hasModalsOpen() } + } + + @Test fun b_startsClean() = uiTest { + app("alice") + assertEquals(1f, appPrefs.densityScale.get()) + assertNull(storedSetting("DensityScale")) + assertFalse(ModalManager.start.hasModalsOpen()) + assertFalse(chatModel.chats.value.any { it.chatInfo.chatViewName == "bob" }) + poll(what = "the chat list") { seesText("Private notes") } + assertFalse(seesText("bob")) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/Reset.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/Reset.kt new file mode 100644 index 0000000000..0250372691 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/Reset.kt @@ -0,0 +1,89 @@ +package chat.simplex.app.ui.harness + +import androidx.compose.runtime.MutableState +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowState +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.simplexWindowState +import chat.simplex.common.views.helpers.* +import kotlinx.coroutines.CompletableDeferred +import java.io.File +import java.util.Properties +import javax.swing.SwingUtilities + +object PrefStores { + private val resources = Class.forName("chat.simplex.common.platform.Resources_desktopKt") + private val settingsProps = staticField(resources, "settingsProps") as Properties + private val themesProps = staticField(resources, "settingsThemesProps") as Properties + private val settingsFile get() = File(desktopPlatform.configPath, "settings.properties") + private val themesFile get() = File(desktopPlatform.configPath, "themes.properties") + private val themesYaml get() = File(getPreferenceFilePath("themes.yaml")) + + private lateinit var settingsCapture: Properties + private lateinit var themesCapture: Properties + private var yamlCapture: ByteArray? = null + + fun capture() { + settingsCapture = settingsProps.clone() as Properties + themesCapture = themesProps.clone() as Properties + yamlCapture = themesYaml.takeIf { it.exists() }?.readBytes() + } + + fun restore() { + restoreProps(settingsProps, settingsCapture, settingsFile) + restoreProps(themesProps, themesCapture, themesFile) + val yaml = yamlCapture + if (yaml == null) themesYaml.delete() else themesYaml.apply { parentFile.mkdirs() }.writeBytes(yaml) + CommonPrefs.reload() + } + + private fun restoreProps(live: Properties, captured: Properties, file: File) { + synchronized(live) { + live.clear() + live.putAll(captured) + file.parentFile.mkdirs() + file.writer().use { live.store(it, "") } + } + } + +} + +object UiState { + fun clearUiState() { + CommonUiState.clear(::onEdt) + onEdt { + simplexWindowState.windowState = WindowState(size = DpSize(1600.dp, 900.dp)) + simplexWindowState.windowFocused.value = true + simplexWindowState.windowVisible.value = true + } + clearTransientUi() + } + + fun clearTransientUi() { + CommonUiState.clearTransient(::onEdt) + onEdt { + completePendingDialogs() + simplexWindowState.toasts.clear() + } + } + + fun completePendingDialogs() { + val onResult = chat.simplex.common.DialogState::class.java.getDeclaredField("onResult\$delegate").apply { isAccessible = true } + with(simplexWindowState) { + for ((ds, empty) in listOf(openDialog to null, saveDialog to null, openMultipleDialog to emptyList())) { + @Suppress("UNCHECKED_CAST") + val d = (onResult.get(ds) as MutableState?>).value + if (d != null && !d.isCompleted) d.complete(empty) + } + } + } +} + +private fun staticField(cls: Class<*>, name: String): Any? = cls.getDeclaredField(name).apply { isAccessible = true }.get(null) + +internal fun onEdt(action: () -> Unit) { + if (SwingUtilities.isEventDispatchThread()) action() else SwingUtilities.invokeAndWait(action) +} + 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 new file mode 100644 index 0000000000..581ff945de --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/TestRoot.kt @@ -0,0 +1,27 @@ +package chat.simplex.app.ui.harness + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.AnnotatedString + +class TestClipboard : ClipboardManager { + @Volatile var content: AnnotatedString? = null + override fun getText(): AnnotatedString? = content + override fun setText(annotatedString: AnnotatedString) { content = annotatedString } +} + +class RecordingUriHandler : UriHandler { + val opened = mutableListOf() + override fun openUri(uri: String) { synchronized(opened) { opened += uri } } +} + +@Composable +fun TestRoot(clipboard: TestClipboard, uriHandler: RecordingUriHandler, content: @Composable () -> Unit) { + CompositionLocalProvider(LocalClipboardManager provides clipboard, LocalUriHandler provides uriHandler) { + content() + } +} 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 new file mode 100644 index 0000000000..39461d6685 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/TestServers.kt @@ -0,0 +1,59 @@ +package chat.simplex.app.ui.harness + +import java.io.File +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +object TestServers { + private const val SLOTS = 120 + private const val KEY_HASH = "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=" + + private class SlotLock(val index: Int, val channel: FileChannel, val lock: FileLock) + + private val slotLock: SlotLock by lazy { + UiEnv.runDir.mkdirs() + (0 until SLOTS).firstNotNullOfOrNull { k -> + val ch = RandomAccessFile(File(UiEnv.runDir, "slot-$k.lock"), "rw").channel + val lock = ch.tryLock() + if (lock != null) SlotLock(k, ch, lock) else { ch.close(); null } + } ?: error("no free port slot in ${UiEnv.runDir}") + } + val slot: Int get() = slotLock.index + val portBase: Int get() = 20000 + 100 * slot + + private var proc: Process? = null + private val responses = LinkedBlockingQueue() + lateinit var smp1: String + private set + val xftp1: String get() = "xftp://$KEY_HASH@localhost:${portBase + 2}" + + fun start() { + 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") + Thread({ + p.inputStream.bufferedReader().forEachLine { line -> + log.appendText("$line\n") + if (line.startsWith("ready ") || line.startsWith("error ")) responses.add(line) + } + }, "uitest-servers").apply { isDaemon = true; start() } + val r = responses.poll(30, TimeUnit.SECONDS) ?: error("uitest-servers did not start; see $log") + check(r.startsWith("ready smp1 ")) { "uitest-servers: $r" } + smp1 = r.removePrefix("ready smp1 ") + } + + fun configure() = configureServers(smp1, xftp1) + + fun stop() { + val p = proc ?: return + proc = null + runCatching { p.outputStream.close() } + if (!p.waitFor(10, TimeUnit.SECONDS)) p.destroyForcibly() + } +} 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 new file mode 100644 index 0000000000..058de1aa71 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiBridge.kt @@ -0,0 +1,61 @@ +package chat.simplex.app.ui.harness + +import java.io.File +import java.net.InetAddress +import java.net.ServerSocket + +object UiBridge { + private val peers = HashMap() + private val sleepWaiter = Waiter { timeoutMs, what, cond -> + val deadline = System.currentTimeMillis() + timeoutMs + while (!cond()) { + if (System.currentTimeMillis() > deadline) throw AssertionError("timed out after $timeoutMs ms waiting for $what") + Thread.sleep(20) + } + } + + @JvmStatic fun main(args: Array) { + val port = args.getOrNull(0)?.toInt() ?: 7777 + UiJvm.setup() + TestServers.start() + 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 -> + while (true) { + server.accept().use { socket -> + val input = socket.getInputStream().bufferedReader(Charsets.UTF_8) + val out = socket.getOutputStream().bufferedWriter(Charsets.UTF_8) + runCatching { + while (true) { + val line = input.readLine() ?: break + val reply = runCatching { handle(line, dir) }.getOrElse { listOf("err " + (it.message ?: it.toString()).replace("\n", " ")) } + reply.forEach { out.write(it); out.write("\n") } + out.flush() + } + } + Peers.stopAll() + peers.clear() + } + } + } + } + + 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}") + "start" -> { + val name = parts[1] + val f = parts.getOrElse(2) { "" }.split(",").toSet() + val peer = CliPeer(name, File(dir, "${System.nanoTime()}-$name"), TestServers.smp1, PeerFlags("reactions" in f, "markRead" in f, "fullLinks" in f), sleepWaiter) + Peers.add(peer); peers[name] = peer + peer.start(); peer.awaitStart() + listOf("ok") + } + "type" -> { peers.getValue(parts[1]).types(parts.getOrElse(2) { "" }); listOf("ok") } + "drain" -> { val ls = peers.getValue(parts[1]).drainLines(); listOf("lines ${ls.size}") + ls } + "stop" -> { peers.remove(parts[1])?.stop(); listOf("ok") } + else -> listOf("err unknown command ${parts[0]}") + } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiEnv.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiEnv.kt new file mode 100644 index 0000000000..a6ff65cfc3 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiEnv.kt @@ -0,0 +1,18 @@ +package chat.simplex.app.ui.harness + +import java.io.File +import java.util.Properties + +object UiEnv { + private val props = Properties().also { p -> + System.getProperty("simplex.uitest.env")?.let(::File)?.takeIf { it.exists() }?.inputStream()?.use { p.load(it) } + } + private fun required(key: String): String = props.getProperty(key)?.takeIf { it.isNotBlank() } ?: error("env.properties has no $key") + val libAppLib: String = props.getProperty("libAppLib") + ?: File("src/commonMain/cpp/desktop/libs/linux-x86_64/libapp-lib.so").absolutePath + val runDir: File = File(props.getProperty("runDir")?.takeIf { it.isNotBlank() } ?: File("build/uitest/run-local").absolutePath) + val jvmDir: File by lazy { File(runDir, "jvm-${TestServers.slot}-${ProcessHandle.current().pid()}") } + val repoRoot: File get() = File(required("repoRoot")) + val cli: String get() = required("cli") + val servers: String get() = required("servers") +} 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 new file mode 100644 index 0000000000..0e87e87083 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiJvm.kt @@ -0,0 +1,24 @@ +package chat.simplex.app.ui.harness + +import java.io.File + +object UiJvm { + fun setup() { + val tmp = File(UiEnv.jvmDir, "tmp") + check(tmp.mkdirs() || tmp.isDirectory) { "cannot create ${tmp.absolutePath}" } + System.setProperty("user.home", UiEnv.jvmDir.absolutePath) + System.setProperty("java.io.tmpdir", tmp.absolutePath) + Runtime.getRuntime().addShutdownHook(Thread { + runCatching { UiTestApp.stopCore() } + Peers.stopAll() + TestServers.stop() + ProcessHandle.current().descendants().forEach { it.destroyForcibly() } + }) + } +} + +object Peers { + private val running = mutableListOf() + @Synchronized fun add(p: CliPeer) { running += p } + @Synchronized fun stopAll() { running.forEach { runCatching { it.stop() } }; running.clear() } +} 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 new file mode 100644 index 0000000000..62ece92f30 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTest.kt @@ -0,0 +1,358 @@ +package chat.simplex.app.ui.harness + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.graphics.toAwtImage +import androidx.compose.ui.test.* +import chat.simplex.common.AppScreen +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.ui.UiKey +import kotlinx.coroutines.test.StandardTestDispatcher +import java.io.File + +abstract class UiTest { + companion object { + init { UiJvm.setup() } + private var testIndex = 0 + private const val HARD_TIMEOUT_MS = 240_000L + } + + fun uiTest(body: UiScope.() -> Unit) { + UiTestApp.bootJvm() + val dir = File(UiEnv.jvmDir, "t${++testIndex}") + var passed = false + val watchdog = java.util.Timer("uitest-watchdog", true).apply { + schedule(object : java.util.TimerTask() { + override fun run() { + println("UITEST hard timeout after ${HARD_TIMEOUT_MS / 1000} s in test ${dir.name}; thread dump follows") + Thread.getAllStackTraces().forEach { (t, st) -> println("--- ${t.name}\n" + st.joinToString("\n") { " at $it" }) } + Runtime.getRuntime().halt(3) + } + }, HARD_TIMEOUT_MS) + } + try { + for (attempt in 1..KNOWN_ISSUE_ATTEMPTS) { + var bodyStarted = false + var bodyCompleted = false + try { + runInternalSkikoComposeUiTest(width = 1600, height = 900, coroutineDispatcher = StandardTestDispatcher()) { + mainClock.autoAdvance = false + bodyStarted = true + UiScope(this, dir).body() + bodyCompleted = true + UiTestApp.drain() + } + break + } catch (e: Throwable) { + val errors = (listOf(e) + e.suppressed).filter { it.javaClass.simpleName !in WRAPPERS } + val known = errors.isNotEmpty() && errors.all(::isKnownAppIssue) + if (!known || (bodyStarted && !bodyCompleted)) { + throw if (e.suppressed.isEmpty()) e else AssertionError("${e.message}; suppressed:\n" + e.suppressed.joinToString("\n") { it.stackTraceToString().lines().take(25).joinToString("\n") }, e) + } + println("UITEST known app issue, not failing the test: " + errors.joinToString { it.message.orEmpty() }) + if (bodyCompleted) break + UiTestApp.drain() + if (attempt == KNOWN_ISSUE_ATTEMPTS) throw e + } + } + passed = true + } finally { + repeat(2) { javax.swing.SwingUtilities.invokeAndWait {} } + UiTestApp.drain() + if (chat.simplex.common.platform.chatController.getChatCtrl() == null) { + println("UITEST the test left no chat controller; initializing an empty core") + UiTestApp.resetForTest(null) + } + watchdog.cancel() + Peers.stopAll() + if (passed) dir.deleteRecursively() + } + } +} + +private const val EXTENSION_MS = 60_000L +private const val SCROLL_ATTEMPTS = 10 +private const val SCROLL_ANIMATION_FRAMES = 40 +private const val FOCUS_SETTLE_FRAMES = 3 +private const val SETTLE_FRAMES = 30 +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 + +class UiScope(val ui: SkikoComposeUiTest, private val dir: File) { + val clipboard = TestClipboard() + val uriHandler = RecordingUriHandler() + + fun app(user: String?): App { + UiTestApp.resetForTest(user) + ui.setContent { TestRoot(clipboard, uriHandler) { AppScreen() } } + ui.waitForIdle() + return App(this) + } + + fun restart(): App { + ui.setContent { } + ui.waitForIdle() + UiTestApp.restartCore() + 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 + } + return App(this) + } + + fun textOf(key: UiKey): String? { + waitFor(key) + val textKey = androidx.compose.ui.semantics.SemanticsProperties.Text + val own = ui.onNode(matcher(key)).fetchSemanticsNode().config.getOrElseNullable(textKey) { null }?.firstOrNull()?.text + return own ?: ui.onAllNodes(hasAnyAncestor(matcher(key)) and SemanticsMatcher.keyIsDefined(textKey), useUnmergedTree = true) + .fetchSemanticsNodes().firstOrNull()?.config?.get(textKey)?.firstOrNull()?.text + } + + fun storedSetting(key: String): String? = + java.util.Properties().apply { File(chat.simplex.common.platform.desktopPlatform.configPath, "settings.properties").inputStream().use { load(it) } }.getProperty(key) + + fun sliderValue(key: UiKey): Float { + waitFor(key) + return ui.onNode(matcher(key)).fetchSemanticsNode().config[androidx.compose.ui.semantics.SemanticsProperties.ProgressBarRangeInfo].current + } + + fun setSlider(key: UiKey, value: Float) { + waitFor(key) + scrollIntoView(key) + waitStable(key) + val node = ui.onNode(matcher(key)) + ui.runOnUiThread { node.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.SetProgress) { it(value) } } + ui.waitForIdle() + } + + fun clipboardText(): String? = clipboard.content?.text + + fun matcher(key: UiKey): SemanticsMatcher = + if (key.id == DESCRIBED) hasContentDescription(key.arg as String) and hasClickAction() else hasTestTag(key.tag) + + fun exists(key: UiKey): Boolean = ui.onAllNodes(matcher(key)).fetchSemanticsNodes().isNotEmpty() + + fun waitFor(key: UiKey, timeoutMs: Long = 10_000) = poll(timeoutMs, "node ${key.tag}") { exists(key) } + + private fun waitPopupsClosedUnlessInside(key: UiKey) { + if (ui.onAllNodes(matcher(key) and hasAnyAncestor(isPopup())).fetchSemanticsNodes().isNotEmpty()) return + poll(what = "open popups to close before acting on ${key.tag}") { ui.onAllNodes(isPopup()).fetchSemanticsNodes().isEmpty() } + } + + + fun scrollTo(key: UiKey) { + poll(what = "a list to scroll to ${key.tag}") { exists(key) || pageListsTo(key) } + } + + private fun pageListsTo(key: UiKey): Boolean { + val lists = ui.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollToIndex) and SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollBy)) + for (i in lists.fetchSemanticsNodes().indices) { + for (page in 1..LIST_PAGES) { + val list = lists.fetchSemanticsNodes().getOrNull(i) ?: break + val before = list.children.map { it.id to it.boundsInRoot } + ui.runOnUiThread { list.config[SemanticsActions.ScrollBy].action?.invoke(0f, list.boundsInRoot.height * SCROLL_PAGE) } + repeat(SCROLL_ANIMATION_FRAMES) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true); ui.waitForIdle() } + if (exists(key)) return true + if (lists.fetchSemanticsNodes().getOrNull(i)?.children?.map { it.id to it.boundsInRoot } == before) break + } + } + return false + } + + + + fun waitStable(key: UiKey) { + var last: androidx.compose.ui.geometry.Rect? = null + var count = 0 + try { + poll(what = "node ${key.tag} to stop moving") { + val ns = ui.onAllNodes(matcher(key)).fetchSemanticsNodes() + count = ns.size + val b = ns.firstOrNull()?.boundsInRoot + (b != null && !b.isEmpty && b == last).also { last = b } + } + } catch (e: AssertionError) { + throw AssertionError("${e.message}; last bounds $last; matching nodes $count", e) + } + } + + fun scrollIntoView(key: UiKey) { + repeat(SCROLL_ATTEMPTS) { + val node = ui.onAllNodes(matcher(key)).fetchSemanticsNodes().firstOrNull() ?: return + 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 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 -> rootBottom * SCROLL_PAGE + b.bottom > rootBottom -> 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() } + } + } + + fun escape() { + repeat(FOCUS_SETTLE_FRAMES) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true); ui.waitForIdle() } + ui.runOnUiThread { chat.simplex.common.simplexWindowState.backstack.lastOrNull()?.invoke() } + ui.waitForIdle() + } + + fun click(key: UiKey) { + waitFor(key) + scrollIntoView(key) + waitStable(key) + poll(what = "node ${key.tag} to be enabled") { isEnabled(key) } + waitPopupsClosedUnlessInside(key) + focusBeforeTap(key) + val target = ui.onNode(matcher(key), useUnmergedTree = key.id != DESCRIBED).fetchSemanticsNode() + val point = tapPointInNode(target) + val node = ui.onNode(matcher(key)) + ui.runOnUiThread { node.performTouchInput { click(point) } } + ui.waitForIdle() + } + + fun settle() { + repeat(SETTLE_FRAMES) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true); ui.waitForIdle() } + } + + private fun focusBeforeTap(key: UiKey) { + if (ui.onAllNodes(matcher(key) and hasAnyAncestor(isPopup())).fetchSemanticsNodes().isNotEmpty()) return + val node = ui.onNode(matcher(key)) + if (!node.fetchSemanticsNode().config.contains(SemanticsActions.RequestFocus)) return + ui.runOnUiThread { node.performSemanticsAction(SemanticsActions.RequestFocus) } + repeat(FOCUS_SETTLE_FRAMES) { ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true); ui.waitForIdle() } + } + + fun enterText(key: UiKey, text: String) { + waitFor(key) + val node = ui.onNode(matcher(key)) + ui.runOnUiThread { node.performTextInput(text) } + ui.waitForIdle() + } + + fun typeInto(key: UiKey, text: String) { + waitFor(key) + val node = ui.onNode(matcher(key)) + val before = editableText(key).orEmpty() + ui.runOnUiThread { node.performTextInput(text) } + poll(what = "field ${key.tag} to hold `$text`") { editableText(key) == before + text } + } + + fun replaceText(key: UiKey, text: String) { + waitFor(key) + val node = ui.onNode(matcher(key)) + val current = editableText(key).orEmpty() + ui.runOnUiThread { node.performTextInputSelection(androidx.compose.ui.text.TextRange(0, current.length)) } + poll(what = "field ${key.tag} to select all") { + ui.onNode(matcher(key)).fetchSemanticsNode().config + .getOrElseNullable(androidx.compose.ui.semantics.SemanticsProperties.TextSelectionRange) { null } == androidx.compose.ui.text.TextRange(0, current.length) + } + ui.runOnUiThread { node.performTextInput(text) } + val replaced = runCatching { poll(timeoutMs = REPLACE_CHECK_MS, what = "field ${key.tag} to hold `$text`") { editableText(key) == text } }.isSuccess + if (!replaced) { + ui.runOnUiThread { node.performTextReplacement(text) } + poll(what = "field ${key.tag} to hold `$text` after replacement") { editableText(key) == text } + } + } + + fun rightClick(key: UiKey) { + waitFor(key) + waitStable(key) + waitPopupsClosedUnlessInside(key) + val node = ui.onNode(matcher(key)) + ui.runOnUiThread { node.performMouseInput { rightClick() } } + ui.waitForIdle() + } + + fun textsOf(key: UiKey): List { + waitFor(key) + val textKey = androidx.compose.ui.semantics.SemanticsProperties.Text + val own = ui.onNode(matcher(key)).fetchSemanticsNode().config.getOrElseNullable(textKey) { null }.orEmpty().map { it.text } + val inner = ui.onAllNodes(hasAnyAncestor(matcher(key)) and SemanticsMatcher.keyIsDefined(textKey), useUnmergedTree = true) + .fetchSemanticsNodes().flatMap { it.config[textKey].map { t -> t.text } } + return own + inner + } + + fun descriptionsOf(key: UiKey): List { + waitFor(key) + val descKey = androidx.compose.ui.semantics.SemanticsProperties.ContentDescription + return ui.onAllNodes((matcher(key) or hasAnyAncestor(matcher(key))) and SemanticsMatcher.keyIsDefined(descKey), useUnmergedTree = true) + .fetchSemanticsNodes().flatMap { it.config[descKey] } + } + + fun isEnabled(key: UiKey): Boolean = + ui.onAllNodes(matcher(key)).fetchSemanticsNodes().firstOrNull() + ?.let { !it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.Disabled) } ?: false + + fun pressEnter(key: UiKey) { + waitPopupsClosedUnlessInside(key) + val node = ui.onNode(matcher(key)) + ui.runOnUiThread { node.performKeyInput { pressKey(androidx.compose.ui.input.key.Key.Enter) } } + ui.waitForIdle() + } + + fun editableText(key: UiKey): String? = + 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 cli(name: String, flags: PeerFlags = PeerFlags()): CliPeer = + CliPeer(name, File(dir, "peers/$name"), TestServers.smp1, 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 + while (true) { + ui.mainClock.advanceTimeBy(16, ignoreFrameDuration = true) + ui.waitForIdle() + if (runCatching(cond).onFailure { lastError = it }.getOrDefault(false)) return + val now = System.currentTimeMillis() + 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) { + 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)}") + } + } + Thread.sleep(16) + } + } + + private var shots = 0 + + private fun failureShot(what: String): String? = runCatching { + val dir = File(UiEnv.runDir, "failures").apply { mkdirs() } + val f = File(dir, "%s-%02d-%s.png".format(UiEnv.jvmDir.name, ++shots, what.replace(Regex("[^A-Za-z0-9]+"), "_").take(60))) + javax.imageio.ImageIO.write(ui.onRoot().captureToImage().toAwtImage(), "png", f) + f.path + }.getOrNull() + + fun texts(): List = + ui.onAllNodes(hasText("", substring = true), useUnmergedTree = true).fetchSemanticsNodes() + .flatMap { it.config.getOrElseNullable(androidx.compose.ui.semantics.SemanticsProperties.Text) { null }.orEmpty() } + .map { it.text } + + fun assumeCamera() {} + + fun seesDescription(text: String, substring: Boolean = false) = + ui.onAllNodes(hasContentDescription(text, substring = substring), useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + + fun seesText(text: String, substring: Boolean = false) = + ui.onAllNodes(hasText(text, substring = substring), useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() +} + +private fun isKnownAppIssue(e: Throwable): Boolean = + e !is AssertionError && generateSequence(e) { it.cause }.any { c -> KNOWN_APP_ISSUES.any { c.message.orEmpty().contains(it) } } + +private val WRAPPERS = setOf("UncaughtExceptionsBeforeTest", "DiagnosticCoroutineContextException") + +private const val KNOWN_ISSUE_ATTEMPTS = 3 +private val KNOWN_APP_ISSUES = listOf("Controller is not initialized", "database closed") 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 new file mode 100644 index 0000000000..d15df7d5a4 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/UiTestApp.kt @@ -0,0 +1,136 @@ +package chat.simplex.app.ui.harness + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowState +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.platform.NtfManager +import chat.simplex.common.simplexWindowState +import chat.simplex.common.ui.theme.ThemeManager +import chat.simplex.common.ui.UiIdle +import chat.simplex.common.ui.uiTestHooksEnabled +import chat.simplex.common.views.call.RcvCallInvitation +import chat.simplex.common.views.database.deleteChatDatabaseFilesAndState +import chat.simplex.common.views.database.stopChatAsync +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingStage +import chat.simplex.common.views.onboarding.setLastVersionDefault +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.Locale + +object UiTestApp { + private var booted = false + + fun bootJvm() { + if (booted) return + booted = true + check(dataDir.startsWith(UiEnv.jvmDir) && File(desktopPlatform.configPath).startsWith(UiEnv.jvmDir)) { + "app paths outside the per-JVM directory: dataDir=$dataDir configPath=${desktopPlatform.configPath}" + } + System.load(UiEnv.libAppLib) + initHS() + Locale.setDefault(Locale.ENGLISH) + uiTestHooksEnabled = true + ntfManager = NoNtfManager + simplexWindowState.windowState = WindowState(size = DpSize(1600.dp, 900.dp)) + tmpDir.mkdirs() + runMigrations() + ThemeManager.applyTheme(appPrefs.currentTheme.get()!!) + appPrefs.appearanceBarsBlurRadius.set(0) + PrefStores.capture() + ModelSnapshot.capture() + } + + private var coreStarted = false + + fun drain() { + val deadline = System.currentTimeMillis() + DRAIN_MS + while (UiIdle.inFlight().isNotEmpty() && System.currentTimeMillis() < deadline) Thread.sleep(20) + val stuck = UiIdle.inFlight() + if (stuck.isNotEmpty()) { + println("UITEST drain gave up after ${DRAIN_MS / 1000} s; abandoned: ${stuck.joinToString { it.what }}") + UiIdle.abandon(stuck) + } + } + + fun resetForTest(user: String?) { + drain() + if (coreStarted) stopAndDeleteCore() + drain() + coreStarted = true + PrefStores.restore() + UiState.clearUiState() + TestServers.start() + DbTemplate.installIfPresent() + if (user != null) TestPrefs.preset() + initCore() + if (!DbTemplate.exists) { + stopCore() + DbTemplate.save() + initCore() + } + if (user == null) return UiState.clearTransientUi() + val u = runBlocking { chatController.apiCreateActiveUser(null, Profile(user, "", null)) } + ?: error("apiCreateActiveUser($user) failed; users ${runCatching { runBlocking { chatController.listUsers(null) }.map { it.user.displayName } }.getOrElse { it.message }}, database ${chatModel.chatDbStatus.value}") + chatModel.currentUser.value = u + chatModel.localUserCreated.value = true + appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) + runBlocking { chatController.startChat(u) } + TestServers.configure() + setLastVersionDefault(chatModel) + UiState.clearTransientUi() + } + + fun restartCore() { + runBlocking { + val ctrl = chatController.getChatCtrl() + if (ctrl != null && ctrl != -1L && chatModel.chatRunning.value != false) chatController.apiStopChat() + } + chatModel.chatRunning.value = null + val ctrl = chatController.getChatCtrl() + if (ctrl != null && ctrl != -1L) chatCloseStore(ctrl).let { check(it.isEmpty()) { "chatCloseStore failed: $it" } } + chatController.setChatCtrl(null) + drain() + UiState.clearUiState() + initChatControllerOnStart() + } + + private fun initCore() { + runBlocking { initChatController() } + check(chatModel.chatDbStatus.value == DBMigrationResult.OK) { "database: ${chatModel.chatDbStatus.value}" } + } + + fun stopCore() { + val deadline = System.currentTimeMillis() + 10_000 + while (chatModel.ctrlInitInProgress.value) { + check(System.currentTimeMillis() < deadline) { "ctrlInitInProgress still set after 10 s" } + Thread.sleep(50) + } + runBlocking { if (chatModel.chatRunning.value == true) stopChatAsync(chatModel) } + val ctrl = chatController.getChatCtrl() + if (ctrl != null && ctrl != -1L) chatCloseStore(ctrl).let { check(it.isEmpty()) { "chatCloseStore failed: $it" } } + chatController.setChatCtrl(null) + } + + private fun stopAndDeleteCore() { + stopCore() + deleteChatDatabaseFilesAndState() + } + + private const val DRAIN_MS = 30_000L + + private object NoNtfManager : NtfManager() { + override fun notifyCallInvitation(invitation: RcvCallInvitation): Boolean = false + override fun hasNotificationsForChat(chatId: String): Boolean = false + override fun cancelNotificationsForChat(chatId: String) {} + override fun cancelNotificationsForUser(userId: Long) {} + override fun displayNotification(user: UserLike, chatId: String, displayName: String, msgText: String, image: String?, actions: List Unit>>) {} + override fun androidCreateNtfChannelsMaybeShowAlert() {} + override fun cancelCallNotification() {} + override fun cancelAllNotifications() {} + override fun showMessage(title: String, text: String) {} + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/VtScreen.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/VtScreen.kt new file mode 100644 index 0000000000..cff01b3bbf --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ui/harness/VtScreen.kt @@ -0,0 +1,103 @@ +package chat.simplex.app.ui.harness + +class VtScreen(private val width: Int, private val height: Int, private val onLine: (String) -> Unit) { + private val rows = Array(height) { IntArray(width) { ' '.code } } + private val continued = BooleanArray(height) + private var row = 0 + private var col = 0 + private var wrapPending = false + + private enum class State { TEXT, ESC, CSI, OSC, OSC_ESC } + private var state = State.TEXT + private val csi = StringBuilder() + private var highSurrogate: Char? = null + + @Synchronized fun feed(text: String) { + for (ch in text) { + when (state) { + State.TEXT -> text(ch) + State.ESC -> state = when (ch) { + '[' -> { csi.setLength(0); State.CSI } + ']' -> State.OSC + else -> State.TEXT + } + State.CSI -> if (ch.code in 0x40..0x7e) { csi(csi.toString(), ch); state = State.TEXT } else csi.append(ch) + State.OSC -> state = when (ch) { '\u0007' -> State.TEXT; '\u001b' -> State.OSC_ESC; else -> State.OSC } + State.OSC_ESC -> state = State.TEXT + } + } + } + + @Synchronized fun cursorRowText(): String = rowText(row) + + private fun text(ch: Char) { + when { + ch == '\u001b' -> state = State.ESC + ch == '\r' -> { col = 0; wrapPending = false } + ch == '\n' -> { onLine(logicalLine(row)); lineFeed() } + ch == '\b' -> { if (col > 0) col--; wrapPending = false } + ch.isHighSurrogate() -> highSurrogate = ch + ch.isLowSurrogate() -> { highSurrogate?.let { put(Character.toCodePoint(it, ch)) }; highSurrogate = null } + ch.code < 0x20 || ch.code == 0x7f -> {} + else -> put(ch.code) + } + } + + private fun put(cp: Int) { + if (wrapPending) { + lineFeed() + continued[row] = true + col = 0 + wrapPending = false + } + rows[row][col] = cp + if (col == width - 1) wrapPending = true else col++ + } + + private fun lineFeed() { + wrapPending = false + if (row == height - 1) { + for (r in 0 until height - 1) { rows[r] = rows[r + 1]; continued[r] = continued[r + 1] } + rows[height - 1] = IntArray(width) { ' '.code } + continued[height - 1] = false + } else row++ + } + + private fun csi(params: String, final: Char) { + if (params.startsWith("?")) return + val p = params.split(';').map { it.toIntOrNull() } + fun arg(i: Int, def: Int) = p.getOrNull(i)?.takeIf { it != 0 } ?: def + wrapPending = false + when (final) { + 'H', 'f' -> { row = (arg(0, 1) - 1).coerceIn(0, height - 1); col = (arg(1, 1) - 1).coerceIn(0, width - 1); continued[row] = false } + 'A' -> row = (row - arg(0, 1)).coerceAtLeast(0) + 'B' -> row = (row + arg(0, 1)).coerceAtMost(height - 1) + 'C' -> col = (col + arg(0, 1)).coerceAtMost(width - 1) + 'D' -> col = (col - arg(0, 1)).coerceAtLeast(0) + 'G' -> col = (arg(0, 1) - 1).coerceIn(0, width - 1) + 'K' -> when (p.getOrNull(0) ?: 0) { + 0 -> rows[row].fill(' '.code, col, width) + 1 -> rows[row].fill(' '.code, 0, col + 1) + 2 -> rows[row].fill(' '.code) + } + 'J' -> when (p.getOrNull(0) ?: 0) { + 0 -> { rows[row].fill(' '.code, col, width); for (r in row + 1 until height) { rows[r].fill(' '.code); continued[r] = false } } + 1 -> { for (r in 0 until row) { rows[r].fill(' '.code); continued[r] = false }; rows[row].fill(' '.code, 0, col + 1) } + 2, 3 -> for (r in 0 until height) { rows[r].fill(' '.code); continued[r] = false } + } + } + } + + private fun rowText(r: Int): String { + val cps = rows[r] + var end = width + while (end > 0 && cps[end - 1] == ' '.code) end-- + return String(cps, 0, end) + } + + private fun logicalLine(r: Int): String { + var start = r + while (start > 0 && continued[start]) start-- + return (start..r).joinToString("") { i -> if (i < r) String(rows[i], 0, width) else rowText(i) }.trimEnd() + } +} 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 new file mode 100644 index 0000000000..77cd9b83c8 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ChatSearchTest.kt @@ -0,0 +1,25 @@ +package chat.simplex.app.ui.chat + +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 ChatSearchTest : UiTest() { + @Test fun search_direct_filtersResults() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val chat = alice.chatList.open("bob") + chat.send("hello there 🙂") + bob expectsMessage "alice> hello there 🙂" + bob sends "@alice hello there" + bob sends "@alice how are you?" + val matches = listOf(chat.itemId("hello there 🙂"), chat.itemId("hello there")) + val other = chat.itemId("how are you?") + chat.search("ello ther") + poll(what = "only the two matching items") { + chat.itemTexts().toSet() == setOf("hello there 🙂", "hello there") && + matches.all { exists(Ui.Chat.Item(it)) } && !exists(Ui.Chat.Item(other)) + } + } +} 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 new file mode 100644 index 0000000000..e8eb361e8b --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/DeleteMessageTest.kt @@ -0,0 +1,24 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertFalse +import org.junit.Test + +class DeleteMessageTest : UiTest() { + @Test fun delete_received_onlyForMe() = 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") + chat.menu("hello", "delete") + waitFor(Ui.Alert.Option(Ui.AlertSource.Shared, "forMe")) + assertFalse(exists(Ui.Alert.Option(Ui.AlertSource.Shared, "forEveryone"))) + click(Ui.Alert.Option(Ui.AlertSource.Shared, "forMe")) + poll(what = "the item removed") { chat.item(id) == null && !exists(Ui.Chat.Item(id)) } + chat.send("ping") + bob expectsMessage "alice> ping" + } +} 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 new file mode 100644 index 0000000000..1f9ba1971d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/EditMessageTest.kt @@ -0,0 +1,20 @@ +package chat.simplex.app.ui.chat + +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 EditMessageTest : UiTest() { + @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") + 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 } + } + } +} 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 new file mode 100644 index 0000000000..b315efce43 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/ForwardTest.kt @@ -0,0 +1,33 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.common.model.CIForwardedFrom +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ForwardTest : UiTest() { + @Test fun forward_contactToContact_peerSeesForwarded() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + bob sends "@alice hey" + alice.chatList.open("bob").forwardTo("hey", "cath") + cath.eventuallyMessage("alice> -> forwarded") + cath expects " hey" + } + + @Test fun forward_toNotes_showsSavedFrom() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hey" + val chat = alice.chatList.open("bob") + chat.forwardTo("hey", "Private notes") + val notes = alice.chatList.chatId("Private notes") + poll(what = "the note saved from bob") { + chatModel.chatId.value == notes && chatModel.chatsContext.chatItems.value.any { (it.meta.itemForwarded as? CIForwardedFrom.Contact)?.chatName == "bob" } + } + 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 } } + } +} 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 new file mode 100644 index 0000000000..2237cfba6b --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/MessagingTest.kt @@ -0,0 +1,75 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.PeerFlags +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.common.ui.Ui +import org.junit.Test + +class MessagingTest : UiTest() { + @Test fun unreadCounterIncrementsAndClears() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice one" + poll(what = "unread badge 1") { "1" in alice.chatList.rowTexts("bob") } + bob sends "@alice two" + poll(what = "unread badge 2") { "2" in alice.chatList.rowTexts("bob") } + alice.chatList.open("bob") + poll(what = "unread badge gone") { alice.chatList.rowTexts("bob").none { it == "1" || it == "2" } } + } + + @Test fun reply_menu_sendQuoted_peerSeesQuote() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hello! how are you?" + val chat = alice.chatList.open("bob") + chat.menu("hello! how are you?", "reply") + chat.send("all good - you?") + bob expectsMessage "alice> > hello! how are you?" + 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) + val chat = alice.chatList.open("bob") + chat.send("hello") + bob expectsMessage "alice> hello" + chat.menu("hello", "delete") + click(Ui.Alert.Option(Ui.AlertSource.Shared, "forEveryone")) + bob expectsMessage "alice> [marked deleted] hello" + } + + @Test fun reaction_add_peerSeesAdded() = uiTest { + val alice = app("alice"); val bob = cli("bob", PeerFlags(reactions = true)) + connect(bob) + bob sends "@alice hi" + alice.chatList.open("bob").react("hi", "👍") + bob expectsMessage "alice> >> hi" + bob expects " + 👍" + } + + @Test fun reaction_received_chipCount() = 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" + chat.react("hi", "👍") + bob expectsMessage "alice> > hi" + bob expects " + 👍" + bob types "+1 alice hi" + poll(what = "reaction chip count 2") { chat.reactionCount("hi", "👍") == "2" } + } +} 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 new file mode 100644 index 0000000000..f6bd29708f --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/NotesTest.kt @@ -0,0 +1,15 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.common.model.CIDirection +import org.junit.Test + +class NotesTest : UiTest() { + @Test fun notes_sendText_saved() = uiTest { + val alice = app("alice") + val notes = alice.chatList.open("Private notes") + notes.send("keep in mind") + val id = notes.itemId("keep in mind") + poll(what = "a local note") { notes.item(id)?.chatDir is CIDirection.LocalSnd } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/SendCompatTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/SendCompatTest.kt new file mode 100644 index 0000000000..51d51b332d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/chat/SendCompatTest.kt @@ -0,0 +1,14 @@ +package chat.simplex.app.ui.chat + +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.UiTest +import org.junit.Test + +class SendCompatTest : UiTest() { + @Test fun sendText_enter_peerReceives() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.open("bob").send("hello there 🙂") + bob expectsMessage "alice> hello there 🙂" + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupsTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupsTest.kt new file mode 100644 index 0000000000..489fec1aa9 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/GroupsTest.kt @@ -0,0 +1,45 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.connectPeers +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class GroupsTest : UiTest() { + @Test fun createGroup_opensChatAndInviteSheet() = uiTest { + val alice = app("alice") + alice.newChat().createGroup("team") + val id = alice.chatList.chatId("team") + poll(what = "the group chat open with the invite members sheet") { chatModel.chatId.value == id && seesText("Invite members") } + } + + @Test fun groupSend_allMembersReceive() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + connectPeers(bob, cath) + hostGroup(bob, "team", "alice", "cath") + joinGroupFromList(alice, 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) + alice.chatList.open("team").send("hello") + bob.eventuallyMessage("#team alice> hello", CONNECT_TIMEOUT_MS) + cath.eventuallyMessage("#team alice> hello", CONNECT_TIMEOUT_MS) + } + + @Test fun leaveGroup_fromChatListMenu() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + alice.chatList.rowMenu("team", "leave") + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.eventually("#team: alice left the group (signed)") + } +} 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 new file mode 100644 index 0000000000..19bdf998b3 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/JoiningCompatTest.kt @@ -0,0 +1,16 @@ +package chat.simplex.app.ui.groups + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import org.junit.Test + +class JoiningCompatTest : UiTest() { + @Test fun groupInvitation_tapRowShowsJoinAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + } +} 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 new file mode 100644 index 0000000000..2c7cf3c7ef --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/groups/MemberInfoTest.kt @@ -0,0 +1,28 @@ +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.ownGroup +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.platform.chatModel +import org.junit.Test + +class MemberInfoTest : UiTest() { + @Test fun memberInfo_changeRoleToAdmin() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + ownGroup(alice, "team", bob).groupInfo().member("bob").changeRole(GroupMemberRole.Admin) + bob.eventually("#team: alice changed your role from member to admin (signed)", CONNECT_TIMEOUT_MS) + poll(what = "bob is admin") { chatModel.groupMembers.value.any { it.memberProfile.displayName == "bob" && it.memberRole == GroupMemberRole.Admin } } + } + + @Test fun memberInfo_removeMember() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + ownGroup(alice, "team", bob, cath).groupInfo().member("bob").remove() + bob.eventually("#team: alice removed you from the group (signed)", CONNECT_TIMEOUT_MS) + 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 } } + } +} 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 new file mode 100644 index 0000000000..8f56a2445c --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListFilterScreens.kt @@ -0,0 +1,104 @@ +package chat.simplex.app.ui.harness + +import androidx.compose.runtime.mutableStateOf +import chat.simplex.common.model.BusinessChatType +import chat.simplex.common.model.ChatInfo +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.chatlist.ActiveFilter +import chat.simplex.common.views.chatlist.PresetTagKind +import kotlinx.coroutines.runBlocking + +fun UiScope.presetFilter(kind: PresetTagKind, label: String) { + quiesce() + val chip = Ui.ChatList.PresetTag(kind.name) + if (exists(chip)) { + poll(what = "the `$label` filter chip") { label in textsOf(chip) } + click(chip) + } else { + click(Ui.ChatList.PresetMenu) + val item = Ui.ChatList.PresetMenuItem(kind.name) + poll(what = "the `$label` item in the filter menu") { exists(item) && label in textsOf(item) } + click(item) + } + poll(what = "the `$label` filter to be active") { chatModel.activeChatTagFilter.value == ActiveFilter.PresetTag(kind) } +} + +fun UiScope.keepOneTimeLink(app: App): String { + val link = app.newChat().createOneTimeLink() + goBack() + poll(what = "the `Keep unused invitation?` alert") { seesText("Keep unused invitation?") } + click(Ui.Alert.Dismiss(Ui.AlertSource.Shared)) + if (exists(Ui.NewChat.CreateLink)) goBack() + poll(what = "the New chat sheet to close with the pending connection kept") { + !exists(Ui.NewChat.CreateLink) && chatModel.chats.value.any { it.chatInfo is ChatInfo.ContactConnection } + } + return link +} + +fun UiScope.namePendingConnection(alias: String) { + var id: String? = null + poll(what = "a pending connection in the model") { + id = chatModel.chats.value.firstOrNull { it.chatInfo is ChatInfo.ContactConnection }?.id + id != null + } + openContextMenu(Ui.ChatList.Row(id!!)) + click(Ui.ChatList.RowMenu(id!!, "setName")) + typeInto(Ui.Info.Alias, alias) + poll(what = "the pending connection alias `$alias` in the model") { + (chatModel.chats.value.firstOrNull { it.id == id }?.chatInfo as? ChatInfo.ContactConnection)?.contactConnection?.localAlias == alias + } + closePendingConnectionInfo() +} + +fun UiScope.closePendingConnectionInfo() { + if (!appPlatform.isAndroid) return + click(described("Back")) + poll(what = "the pending connection info to close") { !exists(Ui.Info.Alias) } +} + +fun UiScope.joinLink(link: String) { + val plan = runBlocking { chatController.apiConnectPlan(null, link, inProgress = mutableStateOf(true)) } + ?: error("apiConnectPlan failed for $link") + runBlocking { chatController.apiConnect(null, incognito = false, connLink = plan.connLink) } + ?: error("apiConnect failed for $link") +} + +fun UiScope.businessChat(biz: CliPeer) { + joinLink(biz.createAddress(business = true)) + biz.eventuallyMatching(Regex("#\\S+: \\S+ joined the group"), CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the business chat `${biz.name}`") { + chatModel.chats.value.any { c -> + c.chatInfo.chatViewName == biz.name && (c.chatInfo as? ChatInfo.Group)?.groupInfo?.businessChat?.chatType == BusinessChatType.Business + } + } + quiesce() +} + +fun CliPeer.createAddress(business: Boolean = false): String { + this sends "/ad" + this expects "Your new chat address is created!" + this expects "" + val link = nextLine() + 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 +} + +fun CliPeer.createGroupLink(group: String): String { + createGroup(group) + this sends "/create link #$group" + this expects "Group link is created!" + this expects "" + 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 = + !isEnabled(Ui.ChatList.Search) && editableText(Ui.ChatList.Search).orEmpty().startsWith("$description (via ") + diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListListsScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListListsScreens.kt new file mode 100644 index 0000000000..5e4b285e16 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListListsScreens.kt @@ -0,0 +1,84 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatTag +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.chatlist.ActiveFilter + +class UserListsScreen(private val s: UiScope) { + fun tag(name: String): ChatTag { + var t: ChatTag? = null + s.poll(what = "list `$name` in the model") { + t = chatModel.userTags.value.firstOrNull { it.chatTagText == name } + t != null + } + return t!! + } + + fun chip(name: String) = Ui.ChatList.UserTag(tag(name).chatTagId) + + fun chipTexts(name: String): List = s.textsOf(chip(name)) + + fun activeTagId(): Long? = (chatModel.activeChatTagFilter.value as? ActiveFilter.UserTag)?.tag?.chatTagId + + fun tagsOf(chat: String): List = ChatListScreen(s).chat(chat).chatInfo.chatTags.orEmpty() + + fun editor() = ListEditorScreen(s) + + fun addList(): ListEditorScreen { + s.click(Ui.ChatList.AddList) + return editor() + } + + fun create(name: String): ChatTag { + addList().apply { typeName(name); save() } + return tag(name) + } + + fun menu(name: String, action: String) { + val id = tag(name).chatTagId + s.openContextMenu(Ui.ChatList.UserTag(id)) + s.click(Ui.ChatList.UserTagMenu(id, action)) + } + + fun filter(name: String) { + val id = tag(name).chatTagId + s.click(Ui.ChatList.UserTag(id)) + s.poll(what = "the `$name` list filter to be active") { activeTagId() == id } + } + + fun assign(chat: String, list: String) { + val id = tag(list).chatTagId + ChatListScreen(s).rowMenu(chat, "tagList") + s.click(Ui.TagList.Tag(id)) + s.poll(what = "`$chat` in list `$list` and the list sheet closed") { tagsOf(chat) == listOf(id) && !s.exists(Ui.TagList.Tag(id)) } + } +} + +fun UiScope.lists() = UserListsScreen(this) + +class ListEditorScreen(private val s: UiScope) { + fun shown(): Boolean = s.exists(Ui.TagEditor.Name) + + fun typeName(text: String) = s.typeInto(Ui.TagEditor.Name, text) + + fun replaceName(text: String) = s.replaceText(Ui.TagEditor.Name, text) + + fun nameText(): String? = s.editableText(Ui.TagEditor.Name) + + fun emojiText(): String? = s.editableText(Ui.TagEditor.Emoji) ?: s.textsOf(Ui.TagEditor.Emoji).firstOrNull() + + fun saveLabel(): String? = s.textOf(Ui.TagEditor.Save) + + fun save() { + s.click(Ui.TagEditor.Save) + s.poll(what = "the list editor to close") { !shown() } + } + + fun saveIgnored() { + s.click(Ui.TagEditor.Save) + s.settle() + s.quiesce() + check(shown()) { "the list editor closed although its save button is disabled" } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListMenusScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListMenusScreens.kt new file mode 100644 index 0000000000..9a8a781147 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListMenusScreens.kt @@ -0,0 +1,10 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.ui.Ui + +fun UiScope.openRowMenu(name: String): String { + val id = ChatListScreen(this).chatId(name) + openContextMenu(Ui.ChatList.Row(id)) + return id +} + diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListRowsScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListRowsScreens.kt new file mode 100644 index 0000000000..55bff9a8e8 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListRowsScreens.kt @@ -0,0 +1,25 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import kotlinx.coroutines.runBlocking + +fun UiScope.switchAwayFromChat(app: App) { + backToChatList() + app.chatList.open("Private notes") + poll(what = "the notes folder to open") { chatModel.chatId.value == app.chatList.chatId("Private notes") } + backToChatList() +} + +fun UiScope.closeInfoToChatList() { + escape() + backToChatList() +} + + +fun UiScope.connectIncognito(peer: CliPeer): String { + val (link, _) = runBlocking { chatController.apiAddContact(null, incognito = true) }.first ?: error("apiAddContact failed") + peer sends "/c ${link.simplexChatUri(short = true)}" + peer expects "confirmation sent!" + return peer.eventuallyMatching(Regex("(\\S+): contact is connected"), CONNECT_TIMEOUT_MS).groupValues[1] +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListToolbarScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListToolbarScreens.kt new file mode 100644 index 0000000000..f33ae1841d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ChatListToolbarScreens.kt @@ -0,0 +1,73 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.OperatorTag +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 + +class ChatListToolbar(private val s: UiScope) { + private val title = Ui.ChatList.Bar.part("title") + + fun titleTexts(): List = s.textsOf(title) + + fun canScrollToTop(): Boolean { + s.waitFor(title) + return s.isEnabled(title) + } + + fun scrollToTop() = s.click(title) + + fun profileBadge(): Boolean = s.textsOf(Ui.ChatList.UserPicker).isNotEmpty() + + fun percentage(): String? = s.textsOf(Ui.ChatList.Subscriptions).firstOrNull { it.endsWith("%") } + + fun toggleShowPercentage() { + s.click(Ui.ChatList.Subscriptions) + s.click(Ui.ServersSummary.ShowPercentage) + s.escape() + s.poll(what = "the servers info screen to close") { !s.exists(Ui.ServersSummary.ShowPercentage) } + } +} + +fun UiScope.chatListToolbar() = ChatListToolbar(this) + +class WhatsNewScreen(private val s: UiScope) { + fun shown(): Boolean = s.seesText(TITLE) + + fun ok() { + s.click(Ui.WhatsNew.Ok) + s.poll(what = "What's New to close") { !s.exists(Ui.WhatsNew.Ok) && !shown() } + } + + companion object { + val VERSION: String by lazy { + Class.forName("chat.simplex.common.views.onboarding.WhatsNewViewKt").getDeclaredField("lastVersion").apply { isAccessible = true }.get(null) as String + } + val TITLE: String get() = "New in $VERSION" + const val OLDER_VERSION = "v6.5" + } +} + +fun UiScope.whatsNewFromSettings(app: App): WhatsNewScreen { + app.settings() + click(Ui.Settings.Help) + click(Ui.Help.WhatsNew) + return WhatsNewScreen(this) +} + +fun UiScope.backToChatList() { + if (appPlatform.isAndroid && chatModel.chatId.value != null) { + escape() + poll(what = "the chat to close") { chatModel.chatId.value == null } + } + waitFor(Ui.ChatList.Search) +} + +fun enableOperator(tag: OperatorTag) { + val groups = runBlocking { chatController.getUserServers(null) } ?: error("getUserServers failed") + val updated = groups.map { g -> g.operator?.takeIf { it.operatorTag == tag }?.let { g.copy(operator = it.copy(enabled = true)) } ?: g } + check(updated != groups) { "no operator $tag to enable" } + check(runBlocking { chatController.setUserServers(null, updated) }) { "setUserServers failed" } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/CommonReset.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/CommonReset.kt new file mode 100644 index 0000000000..b7c6dd2412 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/CommonReset.kt @@ -0,0 +1,110 @@ +package chat.simplex.app.ui.harness + +import androidx.compose.runtime.MutableState +import chat.simplex.common.AppLock +import chat.simplex.common.model.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.platform.VideoPlayerHolder +import kotlinx.coroutines.flow.MutableStateFlow +import java.lang.reflect.Modifier +import java.util.IdentityHashMap + +object ModelSnapshot { + private val restores = mutableListOf<() -> Unit>() + private val excludedTypes = setOf(ChatController::class.java, AppPreferences::class.java) + + fun capture() { + restores.clear() + val seen = IdentityHashMap() + listOf(ChatModel, AppLock, ConnectProgressManager, ChannelRelaysModel, BadgeModel, VideoPlayerHolder).forEach { walk(it, seen) } + } + + fun restore(onMain: (() -> Unit) -> Unit) = onMain { restores.forEach { it() } } + + private fun walk(obj: Any, seen: IdentityHashMap) { + if (seen.put(obj, Unit) != null) return + val cls = obj.javaClass + val isObject = runCatching { cls.getDeclaredField("INSTANCE").apply { isAccessible = true }.get(null) === obj }.getOrDefault(false) + for (f in cls.declaredFields) { + val static = Modifier.isStatic(f.modifiers) + if (static != isObject || f.isSynthetic || f.name == "INSTANCE" || f.name.startsWith("$")) continue + if (f.type in excludedTypes) continue + f.isAccessible = true + val target = if (static) null else obj + val value = f.get(target) + if (!Modifier.isFinal(f.modifiers)) restores += { f.set(target, value) } + val content = if (value is Lazy<*>) value.value else value + if (content != null) captureValue(content) + if (content != null && Modifier.isFinal(f.modifiers) && isAppObject(content)) walk(content, seen) + } + } + + @Suppress("UNCHECKED_CAST") + private fun captureValue(value: Any) { + when { + value is MutableStateFlow<*> -> { val v = value.value; restores += { (value as MutableStateFlow).value = v } } + value is MutableState<*> -> { val v = value.value; restores += { (value as MutableState).value = v } } + value is MutableList<*> -> { val c = value.toList(); restores += { (value as MutableList).apply { clear(); addAll(c) } } } + value is MutableCollection<*> -> { val c = value.toList(); restores += { (value as MutableCollection).apply { clear(); addAll(c) } } } + value is MutableMap<*, *> -> { val c = value.toMap(); restores += { (value as MutableMap).apply { clear(); putAll(c) } } } + } + } + + private fun isAppObject(v: Any): Boolean = + v.javaClass.name.startsWith("chat.simplex.") && !v.javaClass.isEnum && v.javaClass !in excludedTypes +} + +object CommonUiState { + fun clear(onMain: (() -> Unit) -> Unit) { + ModelSnapshot.restore(onMain) + onMain { + CIFile.cachedRemoteFileRequests.clear() + clearImageCaches() + WallpaperType.cachedImages.clear() + setStatic("chat.simplex.common.views.chatlist.ChatListViewKt", "lazyListState", 0 to 0) + setStatic("chat.simplex.common.views.chat.ChatViewKt", "reportsListState", null) + AppLock.clearAuthState() + } + clearTransient(onMain) + } + + fun clearTransient(onMain: (() -> Unit) -> Unit) = onMain { + ModalManager.closeAllModalsEverywhere() + listOf(ModalManager.start, ModalManager.center, ModalManager.end, ModalManager.fullscreen).distinct().forEach { m -> + for (name in listOf("passcodeView", "onTimePasscodeView")) { + @Suppress("UNCHECKED_CAST") + (ModalManager::class.java.getDeclaredField(name).apply { isAccessible = true }.get(m) as MutableStateFlow).value = null + } + } + AlertManager.shared.hideAllAlerts() + AlertManager.privacySensitive.hideAllAlerts() + } + + private fun setStatic(cls: String, name: String, value: Any?) { + Class.forName(cls).getDeclaredField(name).apply { isAccessible = true }.set(null, value) + } +} + +object CommonPrefs { + fun reload() { + AppPreferences::class.java.getDeclaredField("themeOverridesStore").apply { isAccessible = true }.set(null, null) + val stateField = SharedPreference::class.java.getDeclaredField("_state").apply { isAccessible = true } + for (f in AppPreferences::class.java.declaredFields) { + if (!SharedPreference::class.java.isAssignableFrom(f.type)) continue + f.isAccessible = true + val pref = f.get(ChatController.appPrefs) as SharedPreference<*> + @Suppress("UNCHECKED_CAST") + (stateField.get(pref) as MutableState).value = pref.get() + } + val appPrefs = ChatController.appPrefs + chat.simplex.common.ui.theme.ThemeManager.applyTheme(appPrefs.currentTheme.get()!!) + java.util.Locale.setDefault(java.util.Locale.ENGLISH) + val chatModel = ChatModel + chatModel.notificationPreviewMode.value = + runCatching { NotificationPreviewMode.valueOf(appPrefs.notificationPreviewMode.get()!!) }.getOrDefault(NotificationPreviewMode.default) + chatModel.showAuthScreen.value = appPrefs.performLA.get() + chatModel.showChatPreviews.value = appPrefs.privacyShowChatPreviews.get() + chatModel.simplexLinkMode.value = appPrefs.simplexLinkMode.get() + } +} + diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Helpers.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Helpers.kt new file mode 100644 index 0000000000..804bc3b906 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Helpers.kt @@ -0,0 +1,77 @@ +package chat.simplex.app.ui.harness + +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 chat.simplex.common.ui.UiIdle +import chat.simplex.common.ui.UiKey + +const val CONNECT_TIMEOUT_MS = 60_000L +const val QUIET_MS = 1_000L +const val DESCRIBED = "@described" + +fun described(contentDescription: String) = chat.simplex.common.ui.UiKey(DESCRIBED, contentDescription) + +fun UiScope.goBack() { + if (chat.simplex.common.platform.appPlatform.isDesktop) click(described("Back")) else escape() +} + +fun UiScope.quiesce() { + poll(timeoutMs = 30_000, what = "no API calls in flight and no app events for $QUIET_MS ms") { + chat.simplex.common.ui.UiIdle.inFlight().isEmpty() && System.currentTimeMillis() - chat.simplex.common.ui.UiIdle.lastEventAt >= QUIET_MS + } +} + +fun UiScope.connect(peer: CliPeer) { + val (link, _) = kotlinx.coroutines.runBlocking { chat.simplex.common.platform.chatController.apiAddContact(null, incognito = false) }.first + ?: error("apiAddContact failed") + peer sends "/c ${link.simplexChatUri(short = true)}" + peer expects "confirmation sent!" + peer.eventually("${chat.simplex.common.platform.chatModel.currentUser.value!!.displayName}: contact is connected", CONNECT_TIMEOUT_MS) +} + +fun UiScope.connectPeers(a: CliPeer, b: CliPeer) { + val link = a.createOneTimeLink() + b sends "/c $link" + b expects "confirmation sent!" + a.eventually("${b.name}: contact is connected", CONNECT_TIMEOUT_MS) + b.eventually("${a.name}: contact is connected", CONNECT_TIMEOUT_MS) +} + +fun UiScope.hostGroup(host: CliPeer, group: String, vararg members: String) { + host.createGroup(group) + for (m in members) { + host sends "/a $group $m" + host.eventually("invitation to join the group #$group sent to $m") + } +} + +fun CliPeer.createGroup(group: String) { + this sends "/g $group" + this expects "group #$group is created" + this expects "to add members use /a $group or /create link #$group" +} + +fun CliPeer.createOneTimeLink(): String { + this types "/c" + var link: String? = null + while (link == null) link = Regex("(https://\\S+|simplex:/\\S+)").find(nextLine())?.value + return link +} + +fun UiScope.openContextMenu(key: UiKey) { + poll(what = "API calls to finish before opening the menu of ${key.tag}") { UiIdle.inFlight().isEmpty() } + settle() + rightClick(key) +} + +fun UiScope.joinGroupFromList(app: App, host: CliPeer, group: String) { + app.chatList.open(group) + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + host.eventually("#$group: ${chatModel.currentUser.value!!.displayName} joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "membership of #$group to be connected") { + (app.chatList.chat(group).chatInfo as ChatInfo.Group).groupInfo.membership.memberStatus == GroupMemberStatus.MemConnected + } + quiesce() +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/OnboardingScreens.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/OnboardingScreens.kt new file mode 100644 index 0000000000..d9e3c5fd8e --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/OnboardingScreens.kt @@ -0,0 +1,59 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.onboarding.OnboardingStage + +class OnboardingScreen(private val s: UiScope) { + val stage: OnboardingStage get() = appPrefs.onboardingStage.get() + + fun createProfile(name: String) { + s.click(Ui.Onboarding.GetStarted) + s.typeInto(Ui.Onboarding.ProfileName, name) + s.click(Ui.Onboarding.CreateProfile) + s.poll(timeoutMs = 30_000, what = "profile `$name` to be created and the profile step to close") { + chatModel.currentUser.value?.displayName == name && stage != OnboardingStage.Step2_CreateProfile + } + } + + fun useRandomPassphrase() { + s.click(Ui.Onboarding.RandomPassphrase) + s.click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + s.poll(what = "the passphrase step to close") { stage != OnboardingStage.Step2_5_SetupDatabasePassphrase } + } + + fun toYourNetwork(name: String) { + createProfile(name) + if (stage == OnboardingStage.Step2_5_SetupDatabasePassphrase) useRandomPassphrase() + s.poll(timeoutMs = 30_000, what = "the Your network step") { stage == OnboardingStage.Step3_ChooseServerOperators && s.exists(Ui.Onboarding.Continue) } + } + + fun openOperators() { + s.click(Ui.Onboarding.SetupRouters) + s.waitFor(Ui.Onboarding.OperatorsOk) + } + + fun enabledOperatorIds(): Set = + chatModel.conditions.value.serverOperators.filter { it.enabled }.map { it.operatorId }.toSet() + + fun toConditions(name: String) { + toYourNetwork(name) + s.click(Ui.Onboarding.Continue) + s.poll(what = "the Network commitments step") { stage == OnboardingStage.Step4_NetworkCommitments && s.exists(Ui.Onboarding.Accept) } + } + + fun waitForChatList() { + s.poll(timeoutMs = 30_000, what = "onboarding to complete and the chat list to show") { + stage == OnboardingStage.OnboardingComplete && s.exists(Ui.ChatList.NewChat) + } + } + + fun complete(name: String) { + toConditions(name) + s.click(Ui.Onboarding.Accept) + waitForChatList() + } +} + +fun UiScope.onboarding() = OnboardingScreen(this) 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 new file mode 100644 index 0000000000..76d1957672 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/PeerLines.kt @@ -0,0 +1,93 @@ +package chat.simplex.app.ui.harness + +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) + +abstract class PeerLines(val name: String, protected val waiter: Waiter) { + protected val lines = ArrayList() + + protected abstract fun fill() + + protected abstract fun type(text: String) + + infix fun sends(cmd: String) { + type(cmd) + await(LINE_TIMEOUT_MS, "$name: echo of `$cmd`") { takeFirst { it == cmd || stripTime(it) == cmd } != null } + } + + infix fun types(text: String) = type(text) + + infix fun expects(line: String) { + val got = nextLine() + if (got != line) throw AssertionError("$name: expected `$line`, got `$got`; next: ${pending()}") + } + + infix fun expectsMessage(line: String) { + val got = nextLine() + if (stripTime(got) != line) throw AssertionError("$name: expected message `$line`, got `$got`; next: ${pending()}") + } + + fun eventually(line: String, timeoutMs: Long = LINE_TIMEOUT_MS) { + await(timeoutMs, "$name: `$line`") { synchronized(lines) { lines.contains(line) } } + synchronized(lines) { val i = lines.indexOf(line); repeat(i + 1) { lines.removeAt(0) } } + } + + fun eventuallyMessage(line: String, timeoutMs: Long = LINE_TIMEOUT_MS) { + await(timeoutMs, "$name: message `$line`") { synchronized(lines) { lines.any { stripTime(it) == line } } } + synchronized(lines) { val i = lines.indexOfFirst { stripTime(it) == line }; repeat(i + 1) { lines.removeAt(0) } } + } + + fun eventuallyMatching(regex: Regex, timeoutMs: Long = LINE_TIMEOUT_MS): MatchResult { + await(timeoutMs, "$name: a line matching `$regex`") { synchronized(lines) { lines.any { regex.matches(it) } } } + return synchronized(lines) { + val i = lines.indexOfFirst { regex.matches(it) } + regex.matchEntire(lines[i])!!.also { repeat(i + 1) { lines.removeAt(0) } } + } + } + + fun nextLine(timeoutMs: Long = LINE_TIMEOUT_MS): String { + await(timeoutMs, "$name: next output line") { synchronized(lines) { lines.isNotEmpty() } } + return synchronized(lines) { lines.removeAt(0) } + } + + fun contactId(contact: String): Long { + sends("/i @$contact") + var id: Long? = null + await(LINE_TIMEOUT_MS, "$name: contact ID of $contact") { + synchronized(lines) { + val i = lines.indexOfFirst { it.startsWith("contact ID: ") } + if (i >= 0) { + id = lines[i].removePrefix("contact ID: ").trim().toLong() + val end = (i until lines.size).firstOrNull { lines[it].startsWith("peer chat protocol version range") } ?: (lines.size - 1) + repeat(end - i + 1) { lines.removeAt(i) } + true + } else false + } + } + return id!! + } + + fun pending(): List = synchronized(lines) { lines.take(10) } + + protected fun await(timeoutMs: Long, what: String, cond: () -> Boolean) = + try { + waiter.waitUntil(timeoutMs, what) { fill(); cond() } + } catch (e: AssertionError) { + throw AssertionError("${e.message}; $name output: ${pending()}", e) + } + + protected fun stripTime(line: String): String = TIME_PREFIX.replaceFirst(line, "") + + private fun takeFirst(pred: (String) -> Boolean): String? = synchronized(lines) { + val i = lines.indexOfFirst(pred) + if (i >= 0) lines.removeAt(i) else null + } + + companion object { + const val LINE_TIMEOUT_MS = 15_000L + private val TIME_PREFIX = Regex("^\\d{2}:\\d{2} ") + } +} 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 new file mode 100644 index 0000000000..76fd78ad86 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/Screens.kt @@ -0,0 +1,341 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.GroupMemberRole +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui + +class App(private val s: UiScope) { + val chatList = ChatListScreen(s) + + fun userPicker(): UserPickerScreen { + s.click(Ui.ChatList.UserPicker) + return UserPickerScreen(s) + } + + fun profile(): ProfileScreen { + s.click(Ui.ChatList.UserPicker) + s.click(Ui.UserPicker.User(chatModel.currentUser.value!!.userId)) + return ProfileScreen(s) + } + + fun appearance(): AppearanceScreen { + s.click(Ui.ChatList.UserPicker) + s.click(Ui.UserPicker.Settings) + s.click(Ui.Settings.Appearance) + return AppearanceScreen(s) + } + + fun settings() { + s.click(Ui.ChatList.UserPicker) + s.click(Ui.UserPicker.Settings) + } + + fun newChat(): NewChatScreen { + s.click(Ui.ChatList.NewChat) + return NewChatScreen(s) + } +} + +class ChatListScreen(private val s: UiScope) { + fun chatId(name: String): String { + var id: String? = null + s.poll(what = "chat `$name` in the model") { + id = chatModel.chats.value.firstOrNull { it.chatInfo.chatViewName == name }?.id + id != null + } + return id!! + } + + fun row(name: String): ChatListScreen { + s.waitFor(Ui.ChatList.Row(chatId(name))) + return this + } + + fun request(name: String): ChatListScreen { + row(name) + s.poll(what = "the `Open to accept` request row for `$name`") { s.seesText("Open to accept", substring = true) } + return this + } + + fun rowMenu(name: String, action: String, label: String? = null) { + val id = chatId(name) + val item = Ui.ChatList.RowMenu(id, action) + s.openContextMenu(Ui.ChatList.Row(id)) + s.poll(what = "row menu item `$action`" + (label?.let { " labelled `$it`" } ?: "") + " (model settings ${chatModel.chats.value.firstOrNull { it.id == id }?.chatInfo?.chatSettings})") { + s.exists(item) && (label == null || label in s.textsOf(item)) + } + s.click(item) + } + + fun gone(name: String) { + s.poll(what = "chat `$name` to disappear") { chatModel.chats.value.none { it.chatInfo.chatViewName == name } } + } + + fun open(name: String): ChatScreen { + s.click(Ui.ChatList.Row(chatId(name))) + return ChatScreen(s) + } + + fun search(text: String) = s.typeInto(Ui.ChatList.Search, text) + + fun shown(name: String): Boolean = s.exists(Ui.ChatList.Row(chatId(name))) + + fun shareTo(name: String) = s.click(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))) + + fun chat(name: String) = chatModel.chats.value.first { it.chatInfo.chatViewName == name } +} + +class NewChatScreen(private val s: UiScope) { + fun createGroup(name: String) { + s.click(Ui.NewChat.CreateGroup) + s.typeInto(Ui.NewGroup.Name, name) + s.click(Ui.NewGroup.Create) + } + + fun invite(vararg names: String) { + for (name in names) s.click(Ui.InviteMembers.Contact(contactId(name))) + s.click(Ui.InviteMembers.Invite) + s.poll(what = "the invite sheet to close") { !s.exists(Ui.InviteMembers.Invite) } + } + + fun createOneTimeLink(): String { + s.click(Ui.NewChat.CreateLink) + var link: String? = null + s.poll(timeoutMs = 20_000, what = "the one-time link") { + link = s.editableText(Ui.Link.Text)?.takeIf { it.isNotBlank() } + link != null + } + return link!! + } + + private fun contactId(name: String): Long = + chatModel.chats.value.firstNotNullOf { (it.chatInfo as? ChatInfo.Direct)?.contact?.takeIf { c -> c.displayName == name }?.contactId } +} + +class UserPickerScreen(private val s: UiScope) { + fun createAddress(): String { + val a = address() + return a.create().also { a.close() } + } + + fun address(): AddressScreen { + s.click(Ui.UserPicker.Address) + return AddressScreen(s) + } + + fun profiles(): ProfilesScreen { + s.click(Ui.UserPicker.Profiles) + return ProfilesScreen(s) + } + + fun switchTo(name: String) { + s.click(Ui.UserPicker.User(chatModel.users.first { it.user.displayName == name }.user.userId)) + s.poll(what = "`$name` to become the active user") { chatModel.currentUser.value?.displayName == name && !chatModel.switchingUsersAndHosts.value } + s.quiesce() + } +} + +class ProfilesScreen(private val s: UiScope) { + fun add(name: String) { + s.click(Ui.Profiles.Add) + s.typeInto(Ui.NewProfile.Name, name) + s.click(Ui.NewProfile.Create) + s.poll(what = "`$name` to become the active user and the new profile screen to close") { + chatModel.currentUser.value?.displayName == name && !s.exists(Ui.NewProfile.Create) + } + } + + fun close() { + s.escape() + s.poll(what = "the profiles screen to close") { !s.exists(Ui.Profiles.Add) } + } +} + +class AddressScreen(private val s: UiScope) { + fun create(): String { + s.click(Ui.Address.Create) + s.click(Ui.Address.Share) + var link: String? = null + s.poll(what = "the address on the clipboard") { link = s.clipboardText(); link != null } + return link!! + } + + fun close() { + s.escape() + s.poll(what = "the address screen to close") { !s.exists(Ui.Address.Share) } + } +} + +class AppearanceScreen(private val s: UiScope) { + fun colorMode(mode: String) { + s.click(Ui.Appearance.ColorMode) + s.click(Ui.Appearance.ColorMode.option(mode)) + } + + fun colorModeText(): String? = s.textOf(Ui.Appearance.ColorMode) + + var fontScale: Float + get() = s.sliderValue(Ui.Appearance.FontScale) + set(v) = s.setSlider(Ui.Appearance.FontScale, v) + + var zoom: Float + get() = s.sliderValue(Ui.Appearance.Zoom) + set(v) = s.setSlider(Ui.Appearance.Zoom, v) +} + +class ProfileScreen(private val s: UiScope) { + fun rename(name: String) { + s.replaceText(Ui.Profile.Name, name) + s.poll(what = "save to be enabled") { s.isEnabled(Ui.Profile.Save) } + s.click(Ui.Profile.Save) + } +} + +class ChatScreen(private val s: UiScope) { + fun info() { + s.click(Ui.Chat.Bar.part("title")) + } + + fun contactInfo(): ContactInfoScreen { + info() + return ContactInfoScreen(s) + } + + fun groupInfo(): GroupInfoScreen { + info() + return GroupInfoScreen(s) + } + + fun itemId(text: String): Long { + var id: Long? = null + s.poll(what = "chat item `$text`") { + id = chatModel.chatsContext.chatItems.value.lastOrNull { it.content.text == text }?.id + id != null && s.exists(Ui.Chat.Item(id!!)) + } + return id!! + } + + fun item(id: Long): ChatItem? = chatModel.chatsContext.chatItems.value.firstOrNull { it.id == id } + + fun itemTexts(): List = chatModel.chatsContext.chatItems.value.map { it.content.text } + + fun menu(text: String, action: String) { + val id = itemId(text) + s.rightClick(Ui.Chat.Item(id)) + s.click(Ui.Chat.ItemMenu(id, action)) + } + + fun react(text: String, emoji: String) { + val id = itemId(text) + s.rightClick(Ui.Chat.Item(id)) + s.click(Ui.Chat.ItemReaction(id, emoji)) + } + + fun reactionCount(text: String, emoji: String): String? = + s.textsOf(Ui.Chat.ReactionChip(itemId(text), emoji)).firstOrNull { it.all(Char::isDigit) } + + fun forwardTo(text: String, chatName: String) { + menu(text, "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 openSearch() { + s.click(Ui.Chat.BarMenu) + s.click(Ui.Chat.BarMenuItem("search")) + } + + fun search(text: String) { + openSearch() + s.typeInto(Ui.Chat.Bar.part("search"), text) + } + + fun send(text: String) { + s.typeInto(Ui.Chat.Message, text) + enterWhenSendable() + } + + fun replaceAndSend(text: String) { + s.replaceText(Ui.Chat.Message, text) + enterWhenSendable() + } + + private fun enterWhenSendable() { + s.poll(what = "the send button to be enabled") { s.isEnabled(Ui.Chat.Send) } + s.pressEnter(Ui.Chat.Message) + if (fieldClears(ENTER_RETRY_MS)) return + if (!s.editableText(Ui.Chat.Message).isNullOrEmpty() && s.isEnabled(Ui.Chat.Send)) runCatching { s.pressEnter(Ui.Chat.Message) } + s.poll(what = "the message field to clear after the second Enter") { s.editableText(Ui.Chat.Message).isNullOrEmpty() } + } + + private fun fieldClears(timeoutMs: Long): Boolean = + runCatching { s.poll(timeoutMs = timeoutMs, what = "the message field to clear", screenshot = false) { s.editableText(Ui.Chat.Message).isNullOrEmpty() } }.isSuccess + + private companion object { + const val ENTER_RETRY_MS = 5_000L + } +} + +class ContactInfoScreen(private val s: UiScope) { + fun securityCode(): String { + s.click(Ui.Info.VerifyCode) + return s.textOf(Ui.VerifyCode.Code)!!.filterNot(Char::isWhitespace) + } + + fun markVerified() { + s.click(Ui.Info.VerifyCode) + s.click(Ui.VerifyCode.Mark) + s.poll(what = "the security code screen to close") { !s.exists(Ui.VerifyCode.Mark) } + } + + fun verifyRowText(): String? = s.textOf(Ui.Info.VerifyCode) +} + +class GroupInfoScreen(private val s: UiScope) { + fun member(name: String): MemberInfoScreen { + var id: Long? = null + s.poll(what = "member `$name` in the model") { + id = chatModel.groupMembers.value.firstOrNull { it.memberProfile.displayName == name }?.groupMemberId + id != null + } + s.scrollTo(Ui.GroupInfo.Member(id!!)) + s.click(Ui.GroupInfo.Member(id!!)) + return MemberInfoScreen(s) + } +} + +class MemberInfoScreen(private val s: UiScope) { + fun changeRole(role: GroupMemberRole) { + s.click(Ui.MemberInfo.Role) + s.click(Ui.MemberInfo.Role.option(role)) + s.click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + } + + fun remove() { + s.click(Ui.MemberInfo.Remove) + s.click(Ui.Alert.Option(Ui.AlertSource.Shared, "remove")) + } +} + +fun UiScope.ownGroup(app: App, group: String, vararg members: CliPeer): ChatScreen { + val me = chatModel.currentUser.value!!.displayName + app.newChat().apply { + createGroup(group) + invite(*members.map { it.name }.toTypedArray()) + } + for (m in members) { + m.eventually("#$group: $me invites you to join the group as member") + m.eventually("use /j $group to accept") + m sends "/j $group" + m.eventually("#$group: you joined the group", CONNECT_TIMEOUT_MS) + } + quiesce() + return ChatScreen(this) +} 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 new file mode 100644 index 0000000000..f8af563602 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/ServersConfig.kt @@ -0,0 +1,18 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.UserServer +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import kotlinx.coroutines.runBlocking + +fun configureServers(smp: String, xftp: String) { + val groups = runBlocking { chatController.getUserServers(null) } ?: error("getUserServers failed") + fun custom(server: String) = UserServer(remoteHostId = null, serverId = null, server = server, preset = false, enabled = true, deleted = false) + val updated = groups.map { g -> + val op = g.operator + if (op != null) g.copy(operator = op.copy(enabled = false)) + else g.copy(smpServers = g.smpServers.map { it.copy(enabled = false) } + custom(smp), xftpServers = g.xftpServers.map { it.copy(enabled = false) } + custom(xftp)) + }.let { if (it.any { g -> g.operator == null }) it else it + groups.first().copy(operator = null, smpServers = listOf(custom(smp)), xftpServers = listOf(custom(xftp)), chatRelays = emptyList()) } + check(runBlocking { chatController.setUserServers(null, updated) }) { "setUserServers failed" } + chatModel.conditions.value = runBlocking { chatController.getServerOperators(null) } ?: error("getServerOperators failed") +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/TapPoint.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/TapPoint.kt new file mode 100644 index 0000000000..c279a9f302 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/TapPoint.kt @@ -0,0 +1,37 @@ +package chat.simplex.app.ui.harness + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode + +fun tapPointInNode(node: SemanticsNode): Offset { + val bounds = node.boundsInRoot + val minTarget = node.layoutInfo.density.density * MIN_TOUCH_TARGET_DP + val nested = clickableDescendants(node).map { touchTarget(it.boundsInRoot, minTarget) } + val c = bounds.center + if (nested.none { it.contains(c) }) return c - bounds.topLeft + val x = widestFreeMidpoint(bounds.left, bounds.right, nested.filter { c.y >= it.top && c.y < it.bottom }.map { it.left to it.right }) + if (x != null) return Offset(x, c.y) - bounds.topLeft + val y = widestFreeMidpoint(bounds.top, bounds.bottom, nested.filter { c.x >= it.left && c.x < it.right }.map { it.top to it.bottom }) + return Offset(c.x, y ?: c.y) - bounds.topLeft +} + +private fun clickableDescendants(node: SemanticsNode): List = + node.children.flatMap { if (it.config.contains(SemanticsActions.OnClick)) listOf(it) else clickableDescendants(it) } + +private fun widestFreeMidpoint(start: Float, end: Float, covered: List>): Float? { + var free = listOf(start to end) + for ((a, b) in covered) { + free = free.flatMap { (s, e) -> listOf(s to minOf(e, a), maxOf(s, b) to e).filter { it.second > it.first } } + } + return free.maxByOrNull { it.second - it.first }?.let { (it.first + it.second) / 2 } +} + +private fun touchTarget(r: Rect, min: Float): Rect { + val dx = ((min - r.width) / 2).coerceAtLeast(0f) + val dy = ((min - r.height) / 2).coerceAtLeast(0f) + return Rect(r.left - dx, r.top - dy, r.right + dx, r.bottom + dy) +} + +private const val MIN_TOUCH_TARGET_DP = 48f diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/TestPrefs.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/TestPrefs.kt new file mode 100644 index 0000000000..773f0e217d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/harness/TestPrefs.kt @@ -0,0 +1,22 @@ +package chat.simplex.app.ui.harness + +import chat.simplex.common.model.ChatController.appPrefs + +object TestPrefs { + fun preset() { + with(appPrefs) { + laNoticeShown.set(true) + appUpdateNoticeShown.set(true) + oneHandUICardShown.set(true) + addressCreationCardShown.set(true) + getStakeBannerDismissed.set(true) + supporterBannerShown.set(true) + showHiddenProfilesNotice.set(false) + showMuteProfileAlert.set(false) + showDeleteConversationNotice.set(false) + showDeleteContactNotice.set(false) + privacyDeliveryReceiptsSet.set(true) + privacyLinkPreviewsShowAlert.set(false) + } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/AddressCompatTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/AddressCompatTest.kt new file mode 100644 index 0000000000..4589b6afce --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/AddressCompatTest.kt @@ -0,0 +1,13 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.UiTest +import org.junit.Test + +class AddressCompatTest : UiTest() { + @Test fun addressCliConnectsAndRequestAppears() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = alice.userPicker().address().create() + bob sends "/c $address"; bob expects "connection request sent!" + alice.chatList.request("bob") + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListFiltersListsTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListFiltersListsTest.kt new file mode 100644 index 0000000000..dc5e4b8948 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListFiltersListsTest.kt @@ -0,0 +1,339 @@ +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.businessChat +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.lists +import chat.simplex.app.ui.harness.ownGroup +import chat.simplex.app.ui.harness.presetFilter +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 chat.simplex.common.views.chatlist.ActiveFilter +import chat.simplex.common.views.chatlist.PresetTagKind +import org.junit.Test + +class ChatListFiltersListsTest : UiTest() { + @Test fun unreadFilterToggle() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + alice.chatList.row("bob").row("cath").row("Private notes") + alice.chatList.rowMenu("cath", "ntfs.None") + poll(what = "cath muted") { alice.chatList.chat("cath").chatInfo.chatSettings?.enableNtfs == MsgFilter.None } + cath sends "@alice muted hello" + bob sends "@alice hello" + poll(what = "unread count 1 on bob and on muted cath") { "1" in alice.chatList.rowTexts("bob") && "1" in alice.chatList.rowTexts("cath") } + click(Ui.ChatList.UnreadFilter) + poll(what = "only bob listed") { + chatModel.activeChatTagFilter.value == ActiveFilter.Unread && + alice.chatList.shown("bob") && !alice.chatList.shown("cath") && !alice.chatList.shown("Private notes") + } + click(Ui.ChatList.UnreadFilter) + poll(what = "all chats listed again") { + chatModel.activeChatTagFilter.value == null && alice.chatList.shown("bob") && alice.chatList.shown("cath") && alice.chatList.shown("Private notes") + } + } + + @Test fun unreadFilterEmptyState() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob").row("Private notes") + quiesce() + click(Ui.ChatList.UnreadFilter) + poll(what = "`No unread chats` and no rows") { + seesText("No unread chats") && exists(Ui.ChatList.NoUnreadChats) && !alice.chatList.shown("bob") && !alice.chatList.shown("Private notes") + } + click(Ui.ChatList.NoUnreadChats) + poll(what = "the filter cleared and the chats back") { + chatModel.activeChatTagFilter.value == null && alice.chatList.shown("bob") && alice.chatList.shown("Private notes") + } + } + + @Test fun presetFilterFavorites() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + alice.chatList.row("bob").row("cath").row("Private notes") + alice.chatList.rowMenu("bob", "favorite") + poll(what = "bob a favorite") { alice.chatList.chat("bob").chatInfo.chatSettings?.favorite == true } + presetFilter(PresetTagKind.FAVORITES, "Favorites") + poll(what = "only bob listed") { alice.chatList.shown("bob") && !alice.chatList.shown("cath") && !alice.chatList.shown("Private notes") } + } + + @Test fun presetFilterContacts() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + alice.chatList.row("bob").row("team").row("Private notes") + presetFilter(PresetTagKind.CONTACTS, "Contacts") + poll(what = "only bob listed") { alice.chatList.shown("bob") && !alice.chatList.shown("team") && !alice.chatList.shown("Private notes") } + } + + @Test fun presetFilterGroups() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val biz = cli("biz") + connect(bob) + hostGroup(bob, "team", "alice") + businessChat(biz) + alice.chatList.row("bob").row("team").row("biz").row("Private notes") + presetFilter(PresetTagKind.GROUPS, "Groups") + poll(what = "only team listed") { + alice.chatList.shown("team") && !alice.chatList.shown("biz") && !alice.chatList.shown("bob") && !alice.chatList.shown("Private notes") + } + } + + @Test fun presetFilterBusinesses() = uiTest { + val alice = app("alice"); val biz = cli("biz") + businessChat(biz) + alice.chatList.row("biz").row("Private notes") + presetFilter(PresetTagKind.BUSINESS, "Businesses") + poll(what = "only biz listed") { alice.chatList.shown("biz") && !alice.chatList.shown("Private notes") } + } + + @Test fun presetFilterNotes() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob").row("Private notes") + presetFilter(PresetTagKind.NOTES, "Notes") + poll(what = "only Private notes listed") { alice.chatList.shown("Private notes") && !alice.chatList.shown("bob") } + } + + @Test fun presetFilterReports() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + ownGroup(alice, "team", bob, cath) + bob.eventually("#team: new member cath is connected", CONNECT_TIMEOUT_MS) + cath.eventually("#team: member bob is connected", CONNECT_TIMEOUT_MS) + cath sends "#team bad joke" + bob.eventuallyMessage("#team cath> bad joke", CONNECT_TIMEOUT_MS) + bob sends "/report #team content bad joke" + bob.eventuallyMessage("#team (support) > cath bad joke") + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the report counted on team") { + chatModel.chats.value.any { it.chatInfo.chatViewName == "team" && it.chatStats.reportsCount > 0 } + } + backToChatList() + alice.chatList.row("bob").row("cath").row("team").row("Private notes") + presetFilter(PresetTagKind.GROUP_REPORTS, "Reports") + poll(what = "only team listed") { + alice.chatList.shown("team") && !alice.chatList.shown("bob") && !alice.chatList.shown("cath") && !alice.chatList.shown("Private notes") + } + } + + @Test fun presetFiltersCollapsedMenuWhenMany() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + alice.chatList.row("bob").row("team").row("Private notes") + quiesce() + poll(what = "Contacts, Groups and Notes as chips and no preset menu") { + listOf(PresetTagKind.CONTACTS, PresetTagKind.GROUPS, PresetTagKind.NOTES).all { exists(Ui.ChatList.PresetTag(it.name)) } && !exists(Ui.ChatList.PresetMenu) + } + alice.chatList.rowMenu("bob", "favorite") + poll(what = "the presets collapsed into the `All` menu") { + exists(Ui.ChatList.PresetMenu) && "All" in descriptionsOf(Ui.ChatList.PresetMenu) && PresetTagKind.entries.none { exists(Ui.ChatList.PresetTag(it.name)) } + } + click(Ui.ChatList.PresetMenu) + val labels = mapOf(PresetTagKind.FAVORITES to "Favorites", PresetTagKind.CONTACTS to "Contacts", PresetTagKind.GROUPS to "Groups", PresetTagKind.NOTES to "Notes") + poll(what = "Favorites, Contacts, Groups and Notes in the menu and no `All` item") { + labels.all { (kind, label) -> exists(Ui.ChatList.PresetMenuItem(kind.name)) && label in textsOf(Ui.ChatList.PresetMenuItem(kind.name)) } && + !exists(Ui.ChatList.PresetMenuItem(PresetTagKind.CHANNELS.name)) && !exists(Ui.ChatList.PresetMenuAll) + } + click(Ui.ChatList.PresetMenuItem(PresetTagKind.GROUPS.name)) + poll(what = "the Groups filter active and shown as the menu icon") { + chatModel.activeChatTagFilter.value == ActiveFilter.PresetTag(PresetTagKind.GROUPS) && "Groups" in descriptionsOf(Ui.ChatList.PresetMenu) && + alice.chatList.shown("team") && !alice.chatList.shown("bob") + } + } + + @Test fun presetFilterAllResets() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + alice.chatList.row("bob").row("team").row("Private notes") + alice.chatList.rowMenu("bob", "favorite") + poll(what = "the preset menu") { exists(Ui.ChatList.PresetMenu) } + alice.chatList.search("bob") + poll(what = "only bob found") { alice.chatList.shown("bob") && !alice.chatList.shown("team") } + click(Ui.ChatList.PresetMenu) + click(Ui.ChatList.PresetMenuAll) + poll(what = "the search cleared and all chats listed") { + editableText(Ui.ChatList.Search).isNullOrEmpty() && alice.chatList.shown("bob") && alice.chatList.shown("team") && alice.chatList.shown("Private notes") + } + presetFilter(PresetTagKind.CONTACTS, "Contacts") + poll(what = "only bob listed") { alice.chatList.shown("bob") && !alice.chatList.shown("team") && !alice.chatList.shown("Private notes") } + click(Ui.ChatList.PresetMenu) + click(Ui.ChatList.PresetMenuAll) + poll(what = "the filter cleared and all chats listed") { + chatModel.activeChatTagFilter.value == null && "All" in descriptionsOf(Ui.ChatList.PresetMenu) && + alice.chatList.shown("bob") && alice.chatList.shown("team") && alice.chatList.shown("Private notes") + } + } + + @Test fun presetTagsHiddenWhenOnlyOneKind() = uiTest { + val alice = app("alice"); val bob = cli("bob") + alice.chatList.row("Private notes") + quiesce() + poll(what = "only the Notes kind, the tags row shown, and no preset chips or menu") { + chatModel.presetTags.filterValues { it > 0 }.keys == setOf(PresetTagKind.NOTES) && exists(Ui.ChatList.AddList) && + PresetTagKind.entries.none { exists(Ui.ChatList.PresetTag(it.name)) } && !exists(Ui.ChatList.PresetMenu) + } + connect(bob) + alice.chatList.row("bob") + poll(what = "Contacts and Notes chips once there are two kinds") { + exists(Ui.ChatList.PresetTag(PresetTagKind.CONTACTS.name)) && exists(Ui.ChatList.PresetTag(PresetTagKind.NOTES.name)) && !exists(Ui.ChatList.PresetMenu) + } + } + + @Test fun createListFromAddList() = uiTest { + val alice = app("alice") + alice.chatList.row("Private notes") + val lists = lists() + poll(what = "the `Add list` button") { "Add list" in textsOf(Ui.ChatList.AddList) } + val editor = lists.addList() + poll(what = "an empty list editor with `Save list`") { editor.nameText().isNullOrEmpty() && seesText("List name...") && editor.saveLabel() == "Save list" } + editor.typeName("work") + editor.save() + val work = lists.tag("work") + poll(what = "the `work` chip and the `Add list` button turned into +") { + "work" in textsOf(Ui.ChatList.UserTag(work.chatTagId)) && "Add list" !in textsOf(Ui.ChatList.AddList) && "Add list" in descriptionsOf(Ui.ChatList.AddList) + } + check(work.chatTagEmoji == null) { "list created with emoji ${work.chatTagEmoji}" } + } + + @Test fun createListDuplicateNameError() = uiTest { + val alice = app("alice") + alice.chatList.row("Private notes") + val lists = lists() + lists.create("work") + val editor = lists.addList() + poll(what = "the list editor") { editor.shown() } + editor.typeName("work") + editor.saveIgnored() + check(chatModel.userTags.value.map { it.chatTagText } == listOf("work")) { "lists: ${chatModel.userTags.value}" } + editor.replaceName("work2") + editor.save() + poll(what = "lists work and work2") { chatModel.userTags.value.map { it.chatTagText }.toSet() == setOf("work", "work2") } + } + + @Test fun addChatToListFromContextMenu() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + alice.chatList.row("bob").row("cath") + val lists = lists() + alice.chatList.rowMenu("bob", "tagList", "Add to list") + val editor = lists.editor() + poll(what = "the list editor with `Add to list`") { editor.shown() && editor.saveLabel() == "Add to list" } + editor.typeName("work") + editor.save() + val work = lists.tag("work").chatTagId + poll(what = "bob in work") { lists.tagsOf("bob") == listOf(work) } + alice.chatList.rowMenu("cath", "tagList", "Add to list") + poll(what = "the list sheet with work") { exists(Ui.TagList.Tag(work)) && "work" in textsOf(Ui.TagList.Tag(work)) && !editor.shown() } + click(Ui.TagList.Tag(work)) + poll(what = "cath in work and the sheet closed") { lists.tagsOf("cath") == listOf(work) && !exists(Ui.TagList.Tag(work)) } + } + + @Test fun changeChatListFromContextMenu() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + val lists = lists() + val work = lists.create("work").chatTagId + val family = lists.create("family").chatTagId + lists.assign("bob", "work") + alice.chatList.rowMenu("bob", "tagList", "Change list") + poll(what = "the list sheet with work and family") { exists(Ui.TagList.Tag(work)) && exists(Ui.TagList.Tag(family)) } + click(Ui.TagList.Tag(family)) + poll(what = "bob moved from work to family and the sheet closed") { lists.tagsOf("bob") == listOf(family) && !exists(Ui.TagList.Tag(family)) } + } + + @Test fun removeChatFromList() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + val lists = lists() + val work = lists.create("work").chatTagId + lists.assign("bob", "work") + alice.chatList.rowMenu("bob", "tagList", "Change list") + click(Ui.TagList.Tag(work)) + poll(what = "bob out of work and the sheet closed") { lists.tagsOf("bob").isEmpty() && !exists(Ui.TagList.Tag(work)) } + alice.chatList.rowMenu("bob", "tagList", "Add to list") + waitFor(Ui.TagList.Tag(work)) + escape() + poll(what = "the list sheet closed with the work list kept") { !exists(Ui.TagList.Tag(work)) && exists(Ui.ChatList.UserTag(work)) } + } + + @Test fun filterByUserList() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob); connect(cath) + alice.chatList.row("bob").row("cath").row("Private notes") + val lists = lists() + lists.create("work") + lists.assign("bob", "work") + lists.filter("work") + poll(what = "only bob listed") { alice.chatList.shown("bob") && !alice.chatList.shown("cath") && !alice.chatList.shown("Private notes") } + click(lists.chip("work")) + poll(what = "the filter off and all chats listed") { + lists.activeTagId() == null && alice.chatList.shown("bob") && alice.chatList.shown("cath") && alice.chatList.shown("Private notes") + } + } + + @Test fun emptyListShowsNoChatsInList() = uiTest { + val alice = app("alice") + alice.chatList.row("Private notes") + val lists = lists() + lists.create("work") + lists.filter("work") + poll(what = "`No chats in list work.` and no rows") { seesText("No chats in list work.") && !alice.chatList.shown("Private notes") } + } + + @Test fun listUnreadDot() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + val lists = lists() + lists.create("work") + lists.assign("bob", "work") + quiesce() + poll(what = "the work chip without the unread dot") { "work" in lists.chipTexts("work") && "work ●" !in lists.chipTexts("work") } + bob sends "@alice hi" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the unread dot on the work chip") { "work ●" in lists.chipTexts("work") } + alice.chatList.open("bob") + poll(what = "bob's message read") { alice.chatList.chat("bob").chatStats.unreadCount == 0 } + backToChatList() + poll(what = "the unread dot gone") { "work" in lists.chipTexts("work") && "work ●" !in lists.chipTexts("work") } + } + + @Test fun deleteListConfirm() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + val lists = lists() + val work = lists.create("work").chatTagId + lists.assign("bob", "work") + lists.menu("work", "delete") + poll(what = "the `Delete list?` alert") { seesText("Delete list?") && seesText("All chats will be removed from the list work, and the list deleted") } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the work chip gone and bob still listed without a list") { + chatModel.userTags.value.none { it.chatTagId == work } && !exists(Ui.ChatList.UserTag(work)) && alice.chatList.shown("bob") && lists.tagsOf("bob").isEmpty() + } + } + + @Test fun listsArePerUser() = uiTest { + val alice = app("alice") + alice.chatList.row("Private notes") + val lists = lists() + val work = lists.create("work").chatTagId + alice.userPicker().profiles().apply { add("alice2"); close() } + poll(what = "no lists for alice2") { + chatModel.userTags.value.isEmpty() && !exists(Ui.ChatList.UserTag(work)) && exists(Ui.ChatList.AddList) && "Add list" in textsOf(Ui.ChatList.AddList) + } + val home = lists.create("home").chatTagId + alice.userPicker().switchTo("alice") + poll(what = "alice's work list back without alice2's home list") { + chatModel.userTags.value.map { it.chatTagText } == listOf("work") && exists(Ui.ChatList.UserTag(work)) && !exists(Ui.ChatList.UserTag(home)) + } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListMenuTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListMenuTest.kt new file mode 100644 index 0000000000..f9a5a6fa55 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListMenuTest.kt @@ -0,0 +1,49 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ChatListMenuTest : UiTest() { + @Test fun contactMenuMarkReadAndUnread() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice one" + poll(what = "unread badge 1") { "1" in alice.chatList.rowTexts("bob") } + alice.chatList.rowMenu("bob", "markRead") + poll(what = "unread badge gone") { "1" !in alice.chatList.rowTexts("bob") && alice.chatList.chat("bob").chatStats.unreadCount == 0 } + alice.chatList.rowMenu("bob", "markUnread") + poll(what = "the chat marked unread") { alice.chatList.chat("bob").chatStats.unreadChat } + alice.chatList.rowMenu("bob", "markRead") + poll(what = "the unread mark cleared") { !alice.chatList.chat("bob").chatStats.unreadChat } + } + + @Test fun contactMenuFavoriteUnfavorite() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.rowMenu("bob", "favorite") + poll(what = "the favorite star on the row") { "Favorite" in alice.chatList.rowDescriptions("bob") } + alice.chatList.rowMenu("bob", "unfavorite") + poll(what = "the favorite star gone") { "Favorite" !in alice.chatList.rowDescriptions("bob") } + } + + @Test fun contactMenuMuteUnmute() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.rowMenu("bob", "ntfs.None") + poll(what = "the muted icon on the row") { "Notifications" in alice.chatList.rowDescriptions("bob") } + alice.chatList.rowMenu("bob", "ntfs.All") + poll(what = "the muted icon gone") { "Notifications" !in alice.chatList.rowDescriptions("bob") } + } + + @Test fun contactMenuClearChat() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice to be cleared" + poll(what = "the message preview on the row") { "to be cleared" in alice.chatList.rowTexts("bob") } + alice.chatList.rowMenu("bob", "clear") + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the preview gone") { "to be cleared" !in alice.chatList.rowTexts("bob") } + } +} 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 new file mode 100644 index 0000000000..fc9614d686 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListMenusPendingTest.kt @@ -0,0 +1,168 @@ +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.closePendingConnectionInfo +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.createOneTimeLink +import chat.simplex.app.ui.harness.enterLinkInSearch +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.keepOneTimeLink +import chat.simplex.app.ui.harness.openRowMenu +import chat.simplex.app.ui.harness.planConfirm +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 chat.simplex.common.ui.Ui +import org.junit.Test + +private const val PENDING = "invited to connect" +private val PLAN_RESULT = Regex("invitation link: ok to connect|error: connection authorization failed.*") +private val AUTH_FAILED = Regex("error: connection authorization failed - .*") +private val notOffered = listOf("markRead", "markUnread", "favorite", "unfavorite", "ntfs.None", "ntfs.All", "clear") + +class ChatListMenusPendingTest : UiTest() { + @Test fun contactMenuMarkRead() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice one" + poll(what = "unread badge 1") { "1" in alice.chatList.rowTexts("bob") } + alice.chatList.rowMenu("bob", "markRead") + poll(what = "the unread badge gone") { "1" !in alice.chatList.rowTexts("bob") && alice.chatList.chat("bob").chatStats.unreadCount == 0 } + } + + @Test fun contactMenuMarkUnread() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + quiesce() + val emptyTexts = alice.chatList.rowTexts("bob").count { it.isEmpty() } + alice.chatList.rowMenu("bob", "markUnread") + poll(what = "the unread mark and its empty badge on the row") { + alice.chatList.chat("bob").chatStats.unreadChat && alice.chatList.rowTexts("bob").count { it.isEmpty() } > emptyTexts + } + val id = openRowMenu("bob") + poll(what = "`Mark read` offered instead of `Mark unread`") { + exists(Ui.ChatList.RowMenu(id, "markRead")) && !exists(Ui.ChatList.RowMenu(id, "markUnread")) + } + click(Ui.ChatList.RowMenu(id, "markRead")) + poll(what = "the unread mark cleared") { !alice.chatList.chat("bob").chatStats.unreadChat } + } + + @Test fun contactMenuRightClickOpens() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val id = openRowMenu("bob") + poll(what = "the contact menu") { + listOf("markUnread", "favorite", "ntfs.None", "clear", "delete").all { exists(Ui.ChatList.RowMenu(id, it)) } && + listOf("Mark unread", "Favorite", "Mute", "Add to list", "Clear", "Delete").all { seesText(it) } + } + } + + @Test fun groupMenuMuteModesAllMentionsNone() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + fun team() = (alice.chatList.chat("team").chatInfo as ChatInfo.Group).groupInfo + alice.chatList.rowMenu("team", "ntfs.Mentions", "Mute") + poll(what = "mentions-only notifications") { team().chatSettings.enableNtfs == MsgFilter.Mentions } + alice.chatList.rowMenu("team", "ntfs.None", "Mute all") + poll(what = "notifications off") { team().chatSettings.enableNtfs == MsgFilter.None } + alice.chatList.rowMenu("team", "ntfs.All", "Unmute") + poll(what = "all notifications") { team().chatSettings.enableNtfs == MsgFilter.All } + } + + @Test fun notesMenuMarkUnreadAndClear() = uiTest { + val alice = app("alice") + alice.chatList.open("Private notes").send("keep in mind") + backToChatList() + poll(what = "the note in the row preview") { "keep in mind" in alice.chatList.rowTexts("Private notes") } + alice.chatList.rowMenu("Private notes", "markUnread") + poll(what = "the notes marked unread") { alice.chatList.chat("Private notes").chatStats.unreadChat } + alice.chatList.rowMenu("Private notes", "clear") + poll(what = "the `Clear private notes?` alert") { + seesText("Clear private notes?") && seesText("All messages will be deleted - this cannot be undone!") + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the notes cleared and the unread mark reset") { + alice.chatList.chat("Private notes").let { it.chatItems.isEmpty() && !it.chatStats.unreadChat } && + "keep in mind" !in alice.chatList.rowTexts("Private notes") + } + } + + @Test fun pendingInvitationRowIconAndName() = uiTest { + val alice = app("alice") + keepOneTimeLink(alice) + poll(what = "the pending row with its name, description and icon") { + alice.chatList.rowTexts(PENDING).containsAll(listOf(PENDING, "you shared one-time link")) && + "profile image placeholder" in alice.chatList.rowDescriptions(PENDING) && + (alice.chatList.chat(PENDING).chatInfo as ChatInfo.ContactConnection).contactConnection.initiated + } + } + + @Test fun pendingConnectionMenuSetName() = uiTest { + val alice = app("alice") + keepOneTimeLink(alice) + poll(what = "no pending connection info before the menu") { !seesText("You invited a contact") } + alice.chatList.rowMenu(PENDING, "setName") + poll(what = "`You invited a contact` with the name field") { seesText("You invited a contact") && exists(Ui.Info.Alias) } + typeInto(Ui.Info.Alias, "friend") + poll(what = "the pending connection named `friend` in the model") { + chatModel.chats.value.any { it.chatInfo is ChatInfo.ContactConnection && it.chatInfo.chatViewName == "friend" } + } + closePendingConnectionInfo() + poll(what = "the row named `friend`") { "friend" in alice.chatList.rowTexts("friend") } + } + + @Test fun pendingConnectionClickOpensInfo() = uiTest { + val alice = app("alice") + keepOneTimeLink(alice) + poll(what = "no pending connection info before the click") { !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) + alice.chatList.rowMenu(PENDING, "delete") + poll(what = "the `Delete pending connection?` alert") { + seesText("Delete pending connection?") && seesText("The contact you shared this link with will NOT be able to connect!", substring = true) + } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + alice.chatList.gone(PENDING) + 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) + } + + @Test fun contactMenuForNotReadyContactOnlyDelete() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = bob.createOneTimeLink() + alice.chatList.row("Private notes") + enterLinkInSearch(link) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the `Open new chat` alert for bob") { + exists(planConfirm) && "Open new chat" in textsOf(planConfirm) && seesText("bob") + } + click(planConfirm) + poll(what = "the prepared contact bob without a connection") { + (alice.chatList.chat("bob").chatInfo as? ChatInfo.Direct)?.contact?.let { it.nextConnectPrepared && it.activeConn == null } == true + } + backToChatList() + val id = openRowMenu("bob") + poll(what = "a row menu with only `Delete`") { + exists(Ui.ChatList.RowMenu(id, "delete")) && notOffered.none { exists(Ui.ChatList.RowMenu(id, it)) } && !seesText("Add to list") + } + click(Ui.ChatList.RowMenu(id, "delete")) + poll(what = "the `Confirm contact deletion?` alert") { seesText("Confirm contact deletion?") } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListRequestsCardsCompatTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListRequestsCardsCompatTest.kt new file mode 100644 index 0000000000..a0f6cb1131 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListRequestsCardsCompatTest.kt @@ -0,0 +1,22 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.openRowMenu +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ChatListRequestsCardsCompatTest : UiTest() { + @Test fun contactRequestIncognitoHiddenWithShortLinkData() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = alice.userPicker().createAddress() + poll(what = "alice's address with short-link data") { chatModel.userAddress.value?.shortLinkDataSet == true } + bob sends "/c $address"; bob expects "connection request sent!" + alice.chatList.request("bob") + val id = openRowMenu("bob") + poll(what = "the request menu with `Accept` and `Reject` but no `Accept incognito`") { + exists(Ui.ChatList.RowMenu(id, "accept")) && exists(Ui.ChatList.RowMenu(id, "reject")) && + !exists(Ui.ChatList.RowMenu(id, "acceptIncognito")) && !seesText("Accept incognito") + } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListRequestsCardsTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListRequestsCardsTest.kt new file mode 100644 index 0000000000..f3beda6aa7 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListRequestsCardsTest.kt @@ -0,0 +1,133 @@ +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.quiesce +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Test + +class ChatListRequestsCardsTest : UiTest() { + @Test fun contactWithRequestOpenToAccept() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = alice.userPicker().createAddress() + bob sends "/c $address"; bob expects "connection request sent!" + alice.chatList.request("bob").open("bob") + poll(what = "bob's chat with the `Reject` / `Accept` bar instead of the message field") { + chatModel.chatId.value == alice.chatList.chatId("bob") && + exists(Ui.Chat.RequestReject) && "Reject" in textsOf(Ui.Chat.RequestReject) && + exists(Ui.Chat.RequestAccept) && "Accept" in textsOf(Ui.Chat.RequestAccept) && + !exists(Ui.Chat.Message) + } + } + + @Test fun contactWithRequestComposeAccept() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = alice.userPicker().createAddress() + bob sends "/c $address"; bob expects "connection request sent!" + alice.chatList.request("bob").open("bob") + click(Ui.Chat.RequestAccept) + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(what = "the accept bar replaced by the message field") { + !(alice.chatList.chat("bob").chatInfo as ChatInfo.Direct).contact.nextAcceptContactRequest && + !exists(Ui.Chat.RequestAccept) && exists(Ui.Chat.Message) && !seesText("Accept contact request") + } + } + + @Test fun contactWithRequestComposeReject() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = alice.userPicker().createAddress() + bob sends "/c $address"; bob expects "connection request sent!" + alice.chatList.request("bob").open("bob") + click(Ui.Chat.RequestReject) + poll(what = "the `Reject contact request` alert") { seesText("Reject contact request") && seesText("The sender will NOT be notified.") } + poll(what = "the alert's `Reject` button") { "Reject" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + alice.chatList.gone("bob") + poll(what = "back on the chat list with no chat open") { chatModel.chatId.value == null && exists(Ui.ChatList.Search) } + } + + @Test fun oneHandToolbarCardSelectBottomBar() = uiTest { + app("alice") + appPrefs.oneHandUICardShown.set(false) + poll(what = "the `Bottom bar` / `Top bar` card") { + exists(Ui.ChatList.OneHandCard) && "Bottom bar" in textsOf(Ui.ChatList.OneHandCard) && "Top bar" in textsOf(Ui.ChatList.OneHandCard) + } + click(Ui.ChatList.OneHandCard.part("top")) + poll(what = "top toolbars: the floating new-chat button and no toolbar button") { + !appPrefs.oneHandUI.get() && storedSetting("OneHandUI") == "false" && exists(Ui.ChatList.NewChatFab) && !exists(Ui.ChatList.NewChat) + } + click(Ui.ChatList.OneHandCard.part("bottom")) + poll(what = "bottom toolbars: the toolbar new-chat button and no floating button") { + appPrefs.oneHandUI.get() && storedSetting("OneHandUI") == "true" && exists(Ui.ChatList.NewChat) && !exists(Ui.ChatList.NewChatFab) + } + poll(what = "the card still shown") { exists(Ui.ChatList.OneHandCard) && !appPrefs.oneHandUICardShown.get() } + } + + @Test fun oneHandToolbarCardDismissShowsAlert() = uiTest { + app("alice") + appPrefs.oneHandUICardShown.set(false) + click(Ui.ChatList.OneHandCard.part("close")) + poll(what = "the `Reachable app toolbars` alert") { seesText("Reachable app toolbars") && seesText("You can change it in Appearance settings.") } + poll(what = "the card gone and OneHandUICardShown stored") { !exists(Ui.ChatList.OneHandCard) && storedSetting("OneHandUICardShown") == "true" } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert to close") { !seesText("You can change it in Appearance settings.") } + } + + @Test fun oneHandCardAutoHiddenAfterThreeChats() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + alice.chatList.row("bob").row("Private notes") + quiesce() + poll(what = "two listed chats: bob and the notes folder") { chatModel.chats.value.count { !it.chatInfo.contactCard && !it.chatInfo.chatDeleted } == 2 } + appPrefs.oneHandUICardShown.set(false) + poll(what = "the card shown with two chats") { exists(Ui.ChatList.OneHandCard) } + settle() + poll(what = "the card still shown") { exists(Ui.ChatList.OneHandCard) && !appPrefs.oneHandUICardShown.get() } + connect(cath) + alice.chatList.row("cath") + poll(what = "the card gone with three chats and OneHandUICardShown stored") { + !exists(Ui.ChatList.OneHandCard) && storedSetting("OneHandUICardShown") == "true" + } + poll(what = "no `Reachable app toolbars` alert") { !seesText("You can change it in Appearance settings.") } + } + + @Test fun connectBannerCardOpensNewLink() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + quiesce() + appPrefs.addressCreationCardShown.set(false) + val newLink = Ui.ChatList.ConnectBanner.part("newLink") + scrollTo(newLink) + poll(what = "the banner half `New 1-time link`") { "New 1-time link" in textsOf(newLink) } + click(newLink) + poll(what = "New chat on the `1-time link` tab with the link created") { + seesText("New chat") && seesText("1-time link") && seesText("Share this 1-time invite link") && !seesText("Paste the link you received") && + chatModel.showingInvitation.value != null && !editableText(Ui.Link.Text).isNullOrBlank() + } + } + + @Test fun connectBannerCardOpensPasteLink() = uiTest { + assumeCamera() + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + quiesce() + appPrefs.addressCreationCardShown.set(false) + val pasteLink = Ui.ChatList.ConnectBanner.part("pasteLink") + val label = if (appPlatform.isAndroid) "Paste link / Scan" else "Paste link" + scrollTo(pasteLink) + poll(what = "the banner half `$label`") { label in textsOf(pasteLink) } + click(pasteLink) + poll(what = "New chat on the `Connect via link` tab") { + seesText("New chat") && seesText("Connect via link") && seesText("Paste the link you received") && seesText("Tap to paste link") && + !seesText("Share this 1-time invite link") && chatModel.showingInvitation.value == null && + seesText("Or scan QR code") == appPlatform.isAndroid + } + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListRowsTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListRowsTest.kt new file mode 100644 index 0000000000..3329f83dd3 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListRowsTest.kt @@ -0,0 +1,231 @@ +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.closeInfoToChatList +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.connectIncognito +import chat.simplex.app.ui.harness.createAddress +import chat.simplex.app.ui.harness.hostGroup +import chat.simplex.app.ui.harness.joinGroupFromList +import chat.simplex.app.ui.harness.switchAwayFromChat +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.MsgContent +import chat.simplex.common.model.MsgFilter +import chat.simplex.common.model.getTimestampText +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.newchat.planAndConnect +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Test + +private const val PNG_1PX = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + +class ChatListRowsTest : UiTest() { + @Test fun previewShowsLastTextMessage() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice first message" + poll(what = "the first message on the row") { "first message" in alice.chatList.rowTexts("bob") } + bob sends "@alice second message" + poll(what = "the second message in place of the first") { + alice.chatList.rowTexts("bob").let { "second message" in it && "first message" !in it } + } + alice.chatList.open("bob").send("my reply") + bob.eventuallyMessage("alice> my reply") + backToChatList() + poll(what = "alice's own reply on the row") { + alice.chatList.rowTexts("bob").let { "my reply" in it && "second message" !in it } + } + } + + @Test fun previewShowsDraft() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice hello" + poll(what = "bob's message on the row") { "hello" in alice.chatList.rowTexts("bob") } + alice.chatList.open("bob") + typeInto(Ui.Chat.Message, "unsent draft") + switchAwayFromChat(alice) + poll(what = "the draft with the edit icon in place of the message") { + chatModel.draftChatId.value == alice.chatList.chatId("bob") && + alice.chatList.rowTexts("bob").let { t -> t.any { it.endsWith(" unsent draft") } && "hello" !in t } + } + } + + @Test fun previewLink() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val id = bob.contactId("alice") + bob sends """/_send @$id json [{"msgContent":{"type":"link","text":"https://simplex.chat","preview":{"uri":"https://simplex.chat","title":"SimpleX Chat","description":"Private messenger","image":"$PNG_1PX"}}}]""" + bob.eventuallyMessage("@alice https://simplex.chat") + poll(what = "the link text and the link image on the row") { + "https://simplex.chat" in alice.chatList.rowTexts("bob") && alice.chatList.chat("bob").chatItems.lastOrNull()?.content?.msgContent is MsgContent.MCLink + } + } + + @Test fun previewDeletedMessage() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice to be deleted" + poll(what = "the message on the row") { "to be deleted" in alice.chatList.rowTexts("bob") } + bob sends "\\@alice to be deleted" + bob.eventually("message marked deleted") + poll(what = "`marked deleted` in place of the text") { + alice.chatList.rowTexts("bob").let { "marked deleted" in it && "to be deleted" !in it } + } + } + + @Test fun previewContactCardMessage() = uiTest { + val alice = app("alice"); val bob = cli("bob") + bob sends "/p bob Coffee lover" + bob.eventually("user bio changed to Coffee lover (your 0 contacts are notified)") + bob.createAddress() + connect(bob) + bob sends "/share address @alice" + bob.eventuallyMatching(Regex(".*@alice contact address of @bob.*:")) + poll(what = "the card's name and bio on the row") { "bob\nCoffee lover" in alice.chatList.rowTexts("bob") } + } + + @Test fun previewGroupSenderPrefix() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + alice.chatList.open("team").send("mine") + bob.eventuallyMessage("#team alice> mine", CONNECT_TIMEOUT_MS) + backToChatList() + poll(what = "alice's own message without a sender prefix") { + alice.chatList.rowTexts("team").let { t -> "mine" in t && t.none { it.endsWith(": mine") } } + } + bob sends "#team hello all" + poll(what = "bob's message with the `bob: ` prefix") { "bob: hello all" in alice.chatList.rowTexts("team") } + } + + @Test fun unreadCounterGrayWhenMuted() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.rowMenu("bob", "ntfs.None") + poll(what = "bob muted") { alice.chatList.chat("bob").chatInfo.chatSettings?.enableNtfs == MsgFilter.None } + bob sends "@alice one" + poll(what = "unread badge 1") { "1" in alice.chatList.rowTexts("bob") } + bob sends "@alice two" + poll(what = "unread badge 2 in place of the mute icon") { + "2" in alice.chatList.rowTexts("bob") && "Notifications" !in alice.chatList.rowDescriptions("bob") && + alice.chatList.chat("bob").chatInfo.chatSettings?.enableNtfs == MsgFilter.None + } + } + + @Test fun mentionBadgeSingleUnread() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + poll(what = "no @ badge before the mention") { "Notifications" !in alice.chatList.rowDescriptions("team") } + bob sends "#team hello @alice" + poll(what = "the round @ badge without a count") { + alice.chatList.chat("team").chatStats.let { it.unreadCount == 1 && it.unreadMentions == 1 } && + "Notifications" in alice.chatList.rowDescriptions("team") && "1" !in alice.chatList.rowTexts("team") + } + } + + @Test fun mentionIconWithMultipleUnread() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + hostGroup(bob, "team", "alice") + joinGroupFromList(alice, bob, "team") + bob sends "#team hello @alice" + poll(what = "the round @ badge") { "Notifications" in alice.chatList.rowDescriptions("team") } + bob sends "#team and one more" + poll(what = "the @ icon next to the badge 2") { + alice.chatList.chat("team").chatStats.let { it.unreadCount == 2 && it.unreadMentions == 1 } && + "Notifications" in alice.chatList.rowDescriptions("team") && "2" in alice.chatList.rowTexts("team") + } + } + + @Test fun mutedIconWhenNoUnread() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.rowMenu("bob", "ntfs.None") + poll(what = "the mute icon on the row") { "Notifications" in alice.chatList.rowDescriptions("bob") } + bob sends "@alice hi" + poll(what = "the unread badge in place of the mute icon") { + "1" in alice.chatList.rowTexts("bob") && "Notifications" !in alice.chatList.rowDescriptions("bob") + } + alice.chatList.rowMenu("bob", "markRead") + poll(what = "the mute icon back once read") { + "Notifications" in alice.chatList.rowDescriptions("bob") && "1" !in alice.chatList.rowTexts("bob") + } + } + + @Test fun favoriteStarWhenNoUnread() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.rowMenu("bob", "favorite") + poll(what = "the star on the row") { "Favorite" in alice.chatList.rowDescriptions("bob") } + bob sends "@alice hi" + poll(what = "the unread badge in place of the star") { + "1" in alice.chatList.rowTexts("bob") && "Favorite" !in alice.chatList.rowDescriptions("bob") + } + alice.chatList.rowMenu("bob", "markRead") + poll(what = "the star back once read") { + "Favorite" in alice.chatList.rowDescriptions("bob") && "1" !in alice.chatList.rowTexts("bob") + } + } + + @Test fun incognitoIconOnRow() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connectIncognito(bob) + poll(what = "bob connected incognito") { + (alice.chatList.chat("bob").chatInfo as? ChatInfo.Direct)?.contact?.contactConnIncognito == true + } + assertFalse(alice.chatList.chat("Private notes").chatInfo.incognito) + } + + @Test fun verifiedIconOnRow() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob") + assertFalse((alice.chatList.chat("bob").chatInfo as ChatInfo.Direct).contact.verified) + alice.chatList.open("bob").contactInfo().markVerified() + poll(what = "bob verified in the model") { (alice.chatList.chat("bob").chatInfo as ChatInfo.Direct).contact.verified } + closeInfoToChatList() + } + + @Test fun timestampShown() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + bob sends "@alice what time is it" + poll(what = "the message time on the row") { + val item = alice.chatList.chat("bob").chatItems.lastOrNull()?.takeIf { it.content.text == "what time is it" } + item != null && getTimestampText(item.meta.itemTs) in alice.chatList.rowTexts("bob") + } + } + + @Test fun previewContactShouldAcceptForAddress() = uiTest { + val alice = app("alice"); val bob = cli("bob") + bob sends "/ad" + bob expects "Your new chat address is created!" + bob expects "" + val address = bob.nextLine() + runBlocking { planAndConnect(null, address, close = null) } + click(Ui.Alert.Confirm(Ui.AlertSource.PrivacySensitive)) + click(Ui.Chat.Connect) + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + bob.eventually("alice wants to connect to you!", CONNECT_TIMEOUT_MS) + backToChatList() + poll(what = "`contact should accept…` on bob's row") { "contact should accept…" in alice.chatList.rowTexts("bob") } + } + + @Test fun previewGroupInvitationJoinAs() = uiTest { + val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath") + connect(bob) + hostGroup(bob, "team", "alice") + poll(what = "`You are invited to group` on the team row") { "You are invited to group" in alice.chatList.rowTexts("team") } + val incognito = connectIncognito(cath) + hostGroup(cath, "club", incognito) + poll(what = "`Join as $incognito` on the club row") { "Join as $incognito" in alice.chatList.rowTexts("club") } + } +} 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 new file mode 100644 index 0000000000..895c26786c --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatListToolbarSearchTest.kt @@ -0,0 +1,242 @@ +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.chatListToolbar +import chat.simplex.app.ui.harness.connect +import chat.simplex.app.ui.harness.createAddress +import chat.simplex.app.ui.harness.createGroupLink +import chat.simplex.app.ui.harness.enterLinkInSearch +import chat.simplex.app.ui.harness.goBack +import chat.simplex.app.ui.harness.joinLink +import chat.simplex.app.ui.harness.keepOneTimeLink +import chat.simplex.app.ui.harness.namePendingConnection +import chat.simplex.app.ui.harness.planConfirm +import chat.simplex.app.ui.harness.presetFilter +import chat.simplex.app.ui.harness.quiesce +import chat.simplex.app.ui.harness.searchShowsLink +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.GroupProfile +import chat.simplex.common.platform.chatController +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import chat.simplex.common.views.chatlist.PresetTagKind +import chat.simplex.common.views.database.stopChatAsync +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class ChatListToolbarSearchTest : UiTest() { + @Test fun searchFiltersByDisplayName() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob").row("Private notes") + alice.chatList.search("bo") + poll(what = "only bob listed") { alice.chatList.shown("bob") && !alice.chatList.shown("Private notes") } + } + + @Test fun chatListTitleAndProfileButton() = uiTest { + val alice = app("alice") + poll(what = "the title `Chats`") { "Chats" in chatListToolbar().titleTexts() } + check(!exists(Ui.UserPicker.Sheet)) { "the user picker is open before the profile button is clicked" } + alice.userPicker() + poll(what = "the user picker with `Create SimpleX address`") { exists(Ui.UserPicker.Sheet) && textOf(Ui.UserPicker.Address) == "Create SimpleX address" } + } + + @Test fun chatListScrollToTopOnTitleClick() = uiTest { + app("alice") + repeat(GROUPS) { i -> + runBlocking { chatController.apiNewGroup(null, incognito = false, GroupProfile("group$i", "", null)) } ?: error("apiNewGroup failed") + } + restart() + var ids = emptyList() + poll(what = "all chats in the model") { ids = chatModel.chats.value.map { it.id }; ids.size > GROUPS } + val bar = chatListToolbar() + waitFor(Ui.ChatList.Row(ids.first())) + check(!bar.canScrollToTop()) { "the title is clickable while the list is at the top" } + scrollTo(Ui.ChatList.Row(ids.last())) + poll(what = "the title to become clickable with the list scrolled") { bar.canScrollToTop() } + bar.scrollToTop() + poll(what = "the list back at the top with the first chat shown") { !bar.canScrollToTop() && exists(Ui.ChatList.Row(ids.first())) } + } + + @Test fun chatListUnreadDotOnProfileButtonForOtherUsers() = uiTest { + val alice = app("alice"); val bob = cli("bob") + alice.userPicker().profiles().apply { add("work"); close() } + connect(bob) + alice.userPicker().switchTo("alice") + val bar = chatListToolbar() + quiesce() + check(!bar.profileBadge()) { "the profile button has a badge before bob writes" } + bob sends "@work hello" + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the badge on the profile button") { bar.profileBadge() } + alice.userPicker().switchTo("work") + alice.chatList.open("bob") + poll(what = "bob's chat to be read") { alice.chatList.chat("bob").chatStats.unreadCount == 0 } + backToChatList() + alice.userPicker().switchTo("alice") + poll(what = "the badge gone") { !bar.profileBadge() } + } + + @Test fun chatListStoppedChatIndicator() = uiTest { + app("alice") + check(!exists(Ui.ChatList.Stopped)) { "the stopped indicator is shown while chat runs" } + runBlocking { stopChatAsync(chatModel) } + poll(what = "the `Chat is stopped` indicator") { exists(Ui.ChatList.Stopped) && "Chat is stopped" in descriptionsOf(Ui.ChatList.Stopped) } + click(Ui.ChatList.Stopped) + poll(what = "the chat stopped alert") { seesText("Chat is stopped") && seesText(STOPPED_ALERT_TEXT) } + click(Ui.Alert.Confirm(Ui.AlertSource.Shared)) + poll(what = "the alert to close") { !seesText(STOPPED_ALERT_TEXT) } + } + + @Test fun subscriptionIndicatorConnected() = uiTest { + app("alice") + appPrefs.networkShowSubscriptionPercentage.set(true) + val bar = chatListToolbar() + poll(what = "`%` with no subscriptions") { bar.percentage() == "%" } + runBlocking { chatController.apiAddContact(null, incognito = false) }.first ?: error("apiAddContact failed") + poll(what = "`100%` with the invitation queue subscribed") { bar.percentage() == "100%" } + } + + @Test fun subscriptionIndicatorPercentageToggle() = uiTest { + app("alice") + val bar = chatListToolbar() + check(bar.percentage() == null) { "the percentage is shown by default" } + bar.toggleShowPercentage() + poll(what = "the percentage next to the indicator") { bar.percentage() == "%" && storedSetting("ShowSubscriptionPercentage") == "true" } + bar.toggleShowPercentage() + poll(what = "the percentage hidden") { bar.percentage() == null && storedSetting("ShowSubscriptionPercentage") == "false" } + } + + private companion object { + const val GROUPS = 30 + const val STOPPED_ALERT_TEXT = "You can start chat via app Settings / Database or by restarting the app." + } + + @Test fun newChatButtonOpensNewChatSheet() = uiTest { + app("alice") + poll(what = "the toolbar button `Start new chat` and no floating button") { + exists(Ui.ChatList.NewChat) && !exists(Ui.ChatList.NewChatFab) && "Start new chat" in descriptionsOf(Ui.ChatList.NewChat) + } + click(Ui.ChatList.NewChat) + poll(what = "the New chat sheet") { chatModel.newChatSheetVisible.value && exists(Ui.NewChat.CreateLink) && seesText("New chat") } + goBack() + poll(what = "the New chat sheet to close") { !chatModel.newChatSheetVisible.value && !exists(Ui.NewChat.CreateLink) } + appPrefs.oneHandUI.set(false) + poll(what = "the floating button `Start new chat` and no toolbar button") { + exists(Ui.ChatList.NewChatFab) && !exists(Ui.ChatList.NewChat) && "Start new chat" in descriptionsOf(Ui.ChatList.NewChatFab) + } + click(Ui.ChatList.NewChatFab) + poll(what = "the New chat sheet from the floating button") { chatModel.newChatSheetVisible.value && exists(Ui.NewChat.CreateLink) } + } + + @Test fun searchFiltersByAlias() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = keepOneTimeLink(alice) + namePendingConnection("friend") + bob sends "/c $link"; bob expects "confirmation sent!" + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + poll(what = "the contact bob listed under the alias `friend`") { + chatModel.chats.value.any { it.chatInfo is ChatInfo.Direct && it.chatInfo.chatViewName == "friend" } && + chatModel.chats.value.none { it.chatInfo is ChatInfo.ContactConnection } + } + alice.chatList.row("friend").row("Private notes") + alice.chatList.search("frie") + poll(what = "only `friend` listed") { alice.chatList.shown("friend") && !alice.chatList.shown("Private notes") } + val friend = alice.chatList.chatId("friend") + replaceText(Ui.ChatList.Search, "frix") + poll(what = "`friend` listed for text it does not contain only while it is the open chat") { + alice.chatList.shown("friend") == (chatModel.chatId.value == friend) + } + } + + @Test fun searchMatchesPendingConnectionAlias() = uiTest { + val alice = app("alice") + keepOneTimeLink(alice) + namePendingConnection("dentist") + alice.chatList.row("dentist").row("Private notes") + alice.chatList.search("dent") + poll(what = "only the pending connection listed") { alice.chatList.shown("dentist") && !alice.chatList.shown("Private notes") } + } + + @Test fun searchNoChatsFound() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob").row("Private notes") + alice.chatList.search("qq") + poll(what = "`No chats found` and no rows") { + seesText("No chats found") && !alice.chatList.shown("bob") && !alice.chatList.shown("Private notes") + } + } + + @Test fun searchClearsWhenFilterChanges() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.row("bob").row("Private notes") + alice.chatList.search("bo") + poll(what = "only bob listed") { alice.chatList.shown("bob") && !alice.chatList.shown("Private notes") } + presetFilter(PresetTagKind.CONTACTS, "Contacts") + poll(what = "the search text cleared") { editableText(Ui.ChatList.Search).isNullOrEmpty() } + } + + @Test fun searchNameShowsConnectToNameRow() = uiTest { + val alice = app("alice") + alice.chatList.search("bobby") + poll(what = "the row `Connect to bobby.testing`") { + exists(Ui.ChatList.ConnectByName) && "Connect to bobby.testing" in textsOf(Ui.ChatList.ConnectByName) + } + replaceText(Ui.ChatList.Search, "@bob.simplex") + poll(what = "the row `Connect to @bob.simplex`") { + exists(Ui.ChatList.ConnectByName) && "Connect to @bob.simplex" in textsOf(Ui.ChatList.ConnectByName) + } + replaceText(Ui.ChatList.Search, "bob") + poll(what = "no connect row for `bob`") { !exists(Ui.ChatList.ConnectByName) } + } + + @Test fun searchPasteLinkTriggersConnectPlan() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = bob.createAddress() + alice.chatList.row("Private notes") + enterLinkInSearch(address) + poll(what = "the address description in the read-only search field") { searchShowsLink("SimpleX contact address") } + 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") + } + click(Ui.Alert.Dismiss(Ui.AlertSource.PrivacySensitive)) + poll(what = "the search field cleared and editable") { editableText(Ui.ChatList.Search).isNullOrEmpty() && isEnabled(Ui.ChatList.Search) } + } + + @Test fun searchPasteKnownContactLinkFiltersToContact() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = bob.createAddress() + joinLink(address) + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + alice.chatList.row("bob").row("Private notes") + quiesce() + enterLinkInSearch(address) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "only bob listed for his address") { + searchShowsLink("SimpleX contact address") && alice.chatList.shown("bob") && !alice.chatList.shown("Private notes") + } + settle() + poll(what = "no plan alert for a known contact") { !exists(planConfirm) } + } + + @Test fun searchPasteKnownGroupLinkFiltersToGroup() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = bob.createGroupLink("team") + joinLink(link) + bob.eventually("#team: alice joined the group", CONNECT_TIMEOUT_MS) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "alice an active member of team") { + chatModel.chats.value.any { (it.chatInfo as? ChatInfo.Group)?.takeIf { g -> g.chatViewName == "team" }?.groupInfo?.membership?.memberActive == true } + } + alice.chatList.row("team").row("Private notes") + quiesce() + enterLinkInSearch(link) + poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "only team listed for its link") { + searchShowsLink("SimpleX group link") && alice.chatList.shown("team") && !alice.chatList.shown("Private notes") + } + settle() + poll(what = "no plan alert for a known group") { !exists(planConfirm) } + } +} 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 new file mode 100644 index 0000000000..b1f43bdfb9 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ChatProfilesTest.kt @@ -0,0 +1,13 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.common.platform.chatModel +import org.junit.Test + +class ChatProfilesTest : UiTest() { + @Test fun profilesAddProfile() = uiTest { + val alice = app("alice") + alice.userPicker().profiles().add("work") + poll(what = "alice and work listed") { chatModel.users.map { it.user.displayName }.containsAll(listOf("alice", "work")) } + } +} 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 new file mode 100644 index 0000000000..47ff9f34ca --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactInfoTest.kt @@ -0,0 +1,30 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.connect +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContactInfoTest : UiTest() { + @Test fun contactInfoVerifyCodeMatchesCli() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val shown = alice.chatList.open("bob").contactInfo().securityCode() + bob sends "/code alice" + bob.eventually("alice security code:") + assertEquals(bob.nextLine().filterNot(Char::isWhitespace), shown) + } + + @Test fun contactInfoMarkVerified() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + val info = alice.chatList.open("bob").contactInfo() + info.markVerified() + poll(what = "bob verified in the model") { (alice.chatList.chat("bob").chatInfo as ChatInfo.Direct).contact.verified } + poll(what = "the row `View security code`") { info.verifyRowText() == "View security code" } + click(Ui.Info.VerifyCode) + poll(what = "`bob is verified`") { seesText("bob is verified") } + } +} 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 new file mode 100644 index 0000000000..904be9f642 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/ContactsTest.kt @@ -0,0 +1,55 @@ +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.Test + +class ContactsTest : UiTest() { + @Test fun contactRequestMenuAccept() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = alice.userPicker().createAddress() + bob sends "/c $address"; bob expects "connection request sent!" + alice.chatList.request("bob") + alice.chatList.rowMenu("bob", "accept") + bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS) + } + + @Test fun contactRequestMenuReject() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = alice.userPicker().createAddress() + bob sends "/c $address"; bob expects "connection request sent!" + alice.chatList.request("bob") + alice.chatList.rowMenu("bob", "reject") + alice.chatList.gone("bob") + } + + @Test fun deleteContactAndNotify() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.chatList.open("bob").info() + click(Ui.Info.DeleteContact) + click(Ui.Alert.Option(Ui.AlertSource.Shared, "deleteContact")) + click(Ui.Alert.Option(Ui.AlertSource.Shared, "deleteAndNotify")) + bob.eventually("alice deleted contact with you") + alice.chatList.gone("bob") + } + + @Test fun contactInfoSetLocalAlias() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(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`") { "friend" in alice.chatList.rowTexts("friend") } + } + + @Test fun editProfileDisplayName() = uiTest { + val alice = app("alice"); val bob = cli("bob") + connect(bob) + alice.profile().rename("alice2") + bob.eventually("contact alice changed to alice2") + } +} 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 new file mode 100644 index 0000000000..64a53a284d --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/OneTimeLinkCompatTest.kt @@ -0,0 +1,14 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.UiTest +import org.junit.Test + +class OneTimeLinkCompatTest : UiTest() { + @Test fun oneTimeLinkCliConnectsViaShortLink() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = alice.newChat().createOneTimeLink() + bob sends "/c $link"; bob expects "confirmation sent!" + bob expects "alice: contact is connected" + alice.chatList.row("bob") + } +} 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 new file mode 100644 index 0000000000..2900cbab3a --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/OneTimeLinkTest.kt @@ -0,0 +1,25 @@ +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.ChatInfo +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.Ui +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class OneTimeLinkTest : UiTest() { + @Test fun oneTimeLinkSwitchToIncognito() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = alice.newChat().createOneTimeLink() + click(Ui.Link.ShareProfile) + click(Ui.Link.ProfileIncognito) + poll(what = "the incognito footer") { seesText("A new random profile will be shared.") } + bob sends "/c $link"; bob expects "confirmation sent!" + val shared = bob.eventuallyMatching(Regex("(\\S+): contact is connected"), CONNECT_TIMEOUT_MS).groupValues[1] + assertNotEquals("alice", shared) + poll(what = "an incognito contact bob") { + 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/UserPickerTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/UserPickerTest.kt new file mode 100644 index 0000000000..00dadc0cc2 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/UserPickerTest.kt @@ -0,0 +1,17 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.common.model.GroupProfile +import chat.simplex.common.platform.chatController +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class UserPickerTest : UiTest() { + @Test fun userPickerSwitchProfile() = uiTest { + val alice = app("alice") + runBlocking { chatController.apiNewGroup(null, incognito = false, GroupProfile("team", "", null)) } ?: error("apiNewGroup failed") + alice.userPicker().profiles().apply { add("work"); close() } + alice.userPicker().switchTo("alice") + alice.chatList.row("team") + } +} diff --git a/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/WhatsNewTest.kt b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/WhatsNewTest.kt new file mode 100644 index 0000000000..c187fe31bf --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/main/WhatsNewTest.kt @@ -0,0 +1,55 @@ +package chat.simplex.app.ui.main + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.app.ui.harness.WhatsNewScreen +import chat.simplex.app.ui.harness.enableOperator +import chat.simplex.app.ui.harness.whatsNewFromSettings +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.OperatorTag +import chat.simplex.common.platform.chatController +import chat.simplex.common.ui.Ui +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class WhatsNewTest : UiTest() { + @Test fun whatsNewFromSettings() = uiTest { + val alice = app("alice") + val whatsNew = whatsNewFromSettings(alice) + poll(what = "What's New from Help & support, without an OK button") { whatsNew.shown() && !exists(Ui.WhatsNew.Ok) } + escape() + poll(what = "Help & support again") { !whatsNew.shown() && exists(Ui.Help.WhatsNew) } + } + + @Test fun whatsNewShownAfterVersionUpgrade() = uiTest { + app("alice") + appPrefs.whatsNewVersion.set(WhatsNewScreen.OLDER_VERSION) + restart() + val whatsNew = WhatsNewScreen(this) + poll(what = "What's New to open by itself") { whatsNew.shown() && exists(Ui.WhatsNew.Ok) } + poll(what = "the stored What's New version to be the current one") { storedSetting("WhatsNewVersion") == WhatsNewScreen.VERSION } + } + + @Test fun whatsNewOkCloses() = uiTest { + app("alice") + appPrefs.whatsNewVersion.set(WhatsNewScreen.OLDER_VERSION) + restart() + val whatsNew = WhatsNewScreen(this) + poll(what = "What's New to open by itself") { whatsNew.shown() && exists(Ui.WhatsNew.Ok) } + whatsNew.ok() + } + + @Test fun whatsNewShowsViewUpdatedConditions() = uiTest { + app("alice") + enableOperator(OperatorTag.SimpleX) + restart() + val whatsNew = WhatsNewScreen(this) + poll(what = "What's New with `View updated conditions`") { + whatsNew.shown() && exists(Ui.WhatsNew.ViewConditions) && textOf(Ui.WhatsNew.ViewConditions) == "View updated conditions" + } + click(Ui.WhatsNew.ViewConditions) + poll(what = "the `Updated conditions` screen") { seesText("Updated conditions") && seesText("Accept conditions") } + poll(what = "the conditions to be marked as notified in the core") { + runBlocking { chatController.getServerOperators(null) }?.currentConditions?.notifiedAt != null + } + } +} 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 new file mode 100644 index 0000000000..b1c0a77753 --- /dev/null +++ b/apps/multiplatform/common/src/uiTestShared/kotlin/chat/simplex/app/ui/settings/AppearanceTest.kt @@ -0,0 +1,22 @@ +package chat.simplex.app.ui.settings + +import chat.simplex.app.ui.harness.UiTest +import chat.simplex.common.platform.appPlatform +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppearanceTest : UiTest() { + @Test fun appearance_settingsPersistAcrossRestart() = uiTest { + val before = app("alice").appearance() + before.colorMode("DARK") + before.fontScale = 1.2f + if (appPlatform.isDesktop) before.zoom = 1.5f + poll(what = "theme, font scale and zoom to be stored") { + storedSetting("CurrentTheme") == "DARK" && storedSetting("FontScale") == "1.2" && (!appPlatform.isDesktop || storedSetting("DensityScale") == "1.5") + } + val after = restart().appearance() + assertEquals("Dark", after.colorModeText()) + assertEquals(1.2f, after.fontScale, 0.001f) + if (appPlatform.isDesktop) assertEquals(1.5f, after.zoom, 0.001f) + } +} diff --git a/apps/multiplatform/scripts/uitest-android.sh b/apps/multiplatform/scripts/uitest-android.sh new file mode 100755 index 0000000000..7510947111 --- /dev/null +++ b/apps/multiplatform/scripts/uitest-android.sh @@ -0,0 +1,131 @@ +#!/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). +# Env: +# UITEST_SERIAL adb serial of the device or emulator to use; when unset, a Waydroid instance is claimed with wd.sh from PATH +# WD_INSTANCE Waydroid instance (default 2), WD_OWNER claim owner (default uitest-$USER) +# UITEST_APP_ID application id of the app APK (default chat.simplex.app.debug) +# ADB, JAVA_HOME tool locations (default: adb from ANDROID_HOME, ANDROID_SDK_ROOT or PATH; java from JAVA_HOME or PATH) +set -eu +sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +ADB="${ADB:-$( [ -n "$sdk" ] && [ -x "$sdk/platform-tools/adb" ] && echo "$sdk/platform-tools/adb" || echo adb )}" +adb() { "$ADB" "$@"; } +app_apk=$1; test_apk=$2; shift 2 +export WD_INSTANCE="${WD_INSTANCE:-2}" WD_OWNER="${WD_OWNER:-uitest-$(id -un)}" +pkg="${UITEST_APP_ID:-chat.simplex.app.debug}" +runner=$pkg.test/androidx.test.runner.AndroidJUnitRunner +out="$(dirname "$(readlink -f "$0")")/../build/uitest-android/run-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$out" +t0=$(date +%s) +step() { echo "uitest-android: [$(( $(date +%s) - t0 )) s] $*"; } + +mp="$(dirname "$(readlink -f "$0")")/.." +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; } +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" +wd_pid= +cleanup() { + [ -z "${bridge_pid:-}" ] || kill -TERM "$bridge_pid" 2>/dev/null || true + [ -z "$wd_pid" ] || wait "$wd_pid" 2>/dev/null || true + [ -n "${UITEST_SERIAL:-}" ] || wd.sh release >/dev/null 2>&1 || true +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +if [ ! -s "$cp_file" ] || [ -n "$(find $bridge_srcs -newer "$cp_file" 2>/dev/null)" ]; then + step "compiling the bridge" + (cd "$mp" && ./gradlew -g "$mp/.gradle/uitest-home" --no-daemon -q -Pkotlin.compiler.execution.strategy=in-process :common:uitestBridgeClasspath) > "$out/bridge-build.log" 2>&1 \ + || { echo "uitest-android: bridge build failed; see $out/bridge-build.log" >&2; exit 1; } +fi +if [ -z "${UITEST_SERIAL:-}" ]; then + wd.sh claim "$WD_OWNER" + if ! wd.sh status | grep -q "Session:.*RUNNING"; then + wd.sh up > "$out/waydroid-up.log" 2>&1 & + wd_pid=$! + fi +fi +setsid "$java" -Djava.awt.headless=true -Dsimplex.uitest.env="$mp/build/uitest/env.properties" -cp "$(cat "$cp_file")" chat.simplex.app.ui.harness.UiBridge 7777 > "$bridge_log" 2>&1 & +bridge_pid=$! +i=0 +until grep -q "^uitest-bridge: smp" "$bridge_log" 2>/dev/null; do + i=$((i + 1)); [ "$i" -lt 1200 ] || { echo "uitest-android: bridge did not start; see $bridge_log" >&2; exit 1; } + kill -0 "$bridge_pid" 2>/dev/null || { echo "uitest-android: bridge exited; see $bridge_log" >&2; exit 1; } + sleep 0.1 +done +base=$(sed -n 's/^uitest-bridge: .*port base \([0-9]*\),.*/\1/p' "$bridge_log") +step "bridge up, server port base $base" + +if [ -n "$wd_pid" ]; then + wait "$wd_pid" || { echo "uitest-android: wd.sh up failed; see $out/waydroid-up.log" >&2; wd_pid=; exit 1; } + wd_pid= +fi +serial="${UITEST_SERIAL:-$(wd.sh serial)}" +step "device $serial ready" +echo "uitest-android: note: pm clear removes the app's data on $serial" +app_sum=$(sha256sum "$app_apk" | cut -c1-16) +stamp="$mp/build/uitest-android/installed-app-$(printf %s "$serial" | tr -c "A-Za-z0-9" _)" +installed_ver=$(adb -s "$serial" shell dumpsys package "$pkg" 2>/dev/null | tr -d '\r' | sed -n 's/^ *lastUpdateTime=//p' | head -1) +if [ -n "$installed_ver" ] && [ "$(cat "$stamp" 2>/dev/null)" = "$app_sum $installed_ver" ]; then + echo "uitest-android: app APK unchanged on $serial, skipping its install" +else + if [ -n "${UITEST_SERIAL:-}" ]; then adb -s "$serial" install -r -g "$app_apk" >/dev/null; else wd.sh install "$app_apk"; fi + installed_ver=$(adb -s "$serial" shell dumpsys package "$pkg" | tr -d '\r' | sed -n 's/^ *lastUpdateTime=//p' | head -1) + echo "$app_sum $installed_ver" > "$stamp" +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 +prepare_app() { + adb -s "$serial" shell pm clear "$pkg" >/dev/null + for perm in android.permission.POST_NOTIFICATIONS android.permission.CAMERA android.permission.RECORD_AUDIO; do + adb -s "$serial" shell pm grant "$pkg" "$perm" 2>/dev/null || true + done +} +# Prints one line per test in an `am instrument -r` output: "# " for finished tests (0 pass, -2 fail, -3/-4 skipped) +# and "# crashed" for a test that started without finishing. +results() { + tr -d '\r' < "$1" | awk ' + /^INSTRUMENTATION_STATUS: class=/ { c = substr($0, 31) } + /^INSTRUMENTATION_STATUS: test=/ { t = substr($0, 30) } + /^INSTRUMENTATION_STATUS_CODE: / { code = $2; if (code == 1) { running = c "#" t } else { print c "#" t, code; running = "" } } + END { if (running != "") print running, "crashed" }' +} +shots=/sdcard/Android/data/$pkg/files/failures +adb -s "$serial" shell rm -rf "$shots" || true +adb -s "$serial" logcat -G 16M >/dev/null 2>&1 || true +adb -s "$serial" logcat -c || true +: > "$out/results.txt" +pass=1 +gave_up= +while :; do + prepare_app + skip=$(cut -d' ' -f1 "$out/results.txt" | paste -sd, -) + set +e + if [ -n "$skip" ]; then + adb -s "$serial" shell am instrument -w -r "$@" -e notClass "$skip" "$runner" > "$out/instrument-$pass.txt" 2>&1 + else + adb -s "$serial" shell am instrument -w -r "$@" "$runner" > "$out/instrument-$pass.txt" 2>&1 + fi + rc=$? + set -e + results "$out/instrument-$pass.txt" >> "$out/results.txt" + adb -s "$serial" pull "$shots" "$out/" >/dev/null 2>&1 || true + step "instrumentation pass $pass finished (am instrument exit $rc)" + tr -d '\r' < "$out/instrument-$pass.txt" | grep -q "shortMsg=Process crashed" || break + [ "$pass" -lt 4 ] || { echo "uitest-android: process crashed in 4 passes, giving up" >&2; gave_up=1; break; } + pass=$((pass + 1)) + echo "uitest-android: the app process crashed; resuming with the remaining tests (pass $pass)" +done +cat "$out"/instrument-*.txt > "$out/instrument.txt" +adb -s "$serial" logcat -d > "$out/logcat.txt" 2>&1 || true +grep -E "INSTRUMENTATION_STATUS: stack=" "$out/instrument.txt" | cut -c1-1500 || true +grep -E "UITEST|TestRunner: (started|finished|failed)" "$out/logcat.txt" | sed "s/^[0-9-]* //" > "$out/timings.txt" || true +echo "uitest-android: results ($out/results.txt; 0 pass, -2 fail, -3/-4 skipped):" +sed 's/^chat\.simplex\.app\.ui\.//' "$out/results.txt" +echo "uitest-android: $(grep -c ' 0$' "$out/results.txt") passed, $(grep -c ' -2$' "$out/results.txt") failed, $(grep -c ' crashed$' "$out/results.txt") crashed; results in $out" +[ -z "$gave_up" ] && ! grep -qE ' (-2|crashed)$' "$out/results.txt" && [ -s "$out/results.txt" ] diff --git a/apps/multiplatform/scripts/uitest.sh b/apps/multiplatform/scripts/uitest.sh new file mode 100755 index 0000000000..33d2615975 --- /dev/null +++ b/apps/multiplatform/scripts/uitest.sh @@ -0,0 +1,85 @@ +#!/bin/sh +set -eu +self=$(readlink -f "$0") +cd "$(dirname "$self")/.." +home="$PWD/.gradle/uitest-home" +kotlin_in_process="-Pkotlin.compiler.execution.strategy=in-process" + +if [ "${1:-}" = "--inside" ]; then + shift + run="$1"; shift + [ "$$" -eq 1 ] || { echo "uitest: --inside runs only as PID 1 of the runner's PID namespace" >&2; exit 1; } + trap 'kill -TERM -1 2>/dev/null; exit 143' TERM + SIMPLEX_UITEST_RUNNER=1 ./gradlew -g "$home" --offline --no-daemon "$kotlin_in_process" :common:desktopUiTest -PuiRunDir="$run" -PuiPass=1 "$@" + exit $? +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; } +mkdir -p build/uitest +exec 9>build/uitest/runner.lock +flock -n 9 || { echo "another uitest run holds build/uitest/runner.lock" >&2; exit 1; } +run="$PWD/build/uitest/run-$(date +%Y%m%d-%H%M%S)" +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) + +peer_template() { + t="$run/peer-template.tmp" + mkdir -p "$t/tmp" + (cd "$repo" && HOME="$t" TMPDIR="$t/tmp" "$cli" -d "$t/simplex_v1" --maintenance -e /v -t 0 >"$t/out.txt" 2>&1) || true + if [ -f "$t/simplex_v1_chat.db" ] && grep -q "no active user in maintenance mode" "$t/out.txt"; then + rm -rf "$t/tmp" "$t/out.txt" && mv "$t" "$run/peer-template" + else + echo "peer template not created; peers create their databases on first start" >&2 + fi +} +peer_template & +template_pid=$! + +[ -d common/src/commonMain/cpp/desktop/libs/linux-x86_64 ] || { echo "uitest: native libs missing in common/src/commonMain/cpp/desktop/libs/linux-x86_64; build the desktop app once (./gradlew :desktop:cmakeBuildAndCopy)" >&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 + rm -f build/uitest/prepare.key + ./gradlew -g "$home" --no-daemon "$kotlin_in_process" :desktop:cmakeBuildAndCopy :common:desktopTestClasses :common:uitestPrepare + ./gradlew -g "$home" --stop >/dev/null 2>&1 || true + echo "$prepare_key" > build/uitest/prepare.key +fi + +cat > build/uitest/env.properties </jvm--`, with `user.home` and `java.io.tmpdir` inside it; + - `bootJvm`: path check, `System.load(libapp-lib)` and `initHS`, English locale, `uiTestHooksEnabled = true`, a no-op `NtfManager` (records nothing), window state 1600 × 900, `runMigrations`, theme applied, toolbar blur 0, `PrefStores.capture`, `ModelSnapshot.capture`; + - the shutdown hook stops the core, the peers and `uitest-servers`, then destroys the remaining descendant processes. +- **`uiTest { }`** (no parameters): + - runs `runInternalSkikoComposeUiTest(1600, 900, StandardTestDispatcher())` with `autoAdvance = false`, then the body, then `drain()` inside the scene; + - `finally`: two EDT flushes, `drain()`, `resetForTest(null)` if the test left no controller, peers stopped, and `/t` deleted after a pass only; + - the known-issue policy is in 0.5.8. +- **`app(user)`** (`null` = onboarding boot) runs `resetForTest(user)` and composes `TestRoot { AppScreen() }`. `resetForTest`, in order: + 1. `drain`; + 2. stop the core (wait at most 10 s for `ctrlInitInProgress`, `stopChatAsync`, `chatCloseStore`, `setChatCtrl(null)`) and `deleteChatDatabaseFilesAndState()`; + 3. `drain`, `PrefStores.restore`, `clearUiState`, `TestServers.start()` (once per JVM), `DbTemplate.installIfPresent`, `TestPrefs.preset()` (onboarded boots only); + 4. `initChatController`; the first JVM without a template stops the core, publishes the template with an atomic rename and initialises again; + 5. with a user: `apiCreateActiveUser`, model and onboarding stage set, `startChat`, `configureServers`, `setLastVersionDefault`, `clearTransientUi`. +- **`restart()`**: empty composition; `restartCore` (`apiStopChat` if running, `chatRunning = null`, `chatCloseStore`, `setChatCtrl(null)`, `drain`, `clearUiState`, `initChatControllerOnStart`); compose again; poll (30 s) until `chatDbStatus != null` and init has finished. +- **`TestRoot`**: an in-memory `LocalClipboardManager` and a recording `LocalUriHandler`. +- **`poll(timeout = 10 s, what) { cond }`**: + - each iteration: `advanceTimeBy(16)`, `waitForIdle`, the condition, `Thread.sleep(16)`; a condition that throws counts as "not yet", and its last error goes into the timeout message; + - past the deadline it keeps waiting while any `UiIdle` item that was in flight at the deadline still is, up to 60 s more; + - a timeout writes `/failures/-NN-.png` and reports the in-flight list and the first 60 visible texts; + - `settle()` advances 30 frames of 16 ms. +- **Actions:** + - `click`: wait for the node; `scrollIntoView` (at most 10 `ScrollBy` steps: by the overflow plus 50 px, or 0.6 of a page for an unplaced node, then 40 frames); non-empty stable bounds; enabled; no open popup unless the target is inside one; `RequestFocus` and 3 frames; then a touch tap at `tapPointInNode` (`TapPoint.kt`: the centre, or the middle of the widest span no nested clickable covers, nested targets widened to 48 dp); + - `rightClick`: a mouse right click; + - `typeInto`: `performTextInput`, verified through `EditableText`; `enterText`: the same without verification; + - `replaceText`: select all, input, and `performTextReplacement` if the field doesn't hold the text after 2 s; + - `pressEnter`: the Enter key; `escape()`: 3 frames, then the window back stack; + - `scrollTo(key)`: pages each lazy list (`ScrollBy` 0.6 page, 40 frames, at most 20 pages) until the key appears; `performScrollTo*` is never used (15.4 item 20); + - `setSlider`: `SetProgress`. +- **Reads:** `exists` (any match), `waitFor`, `textOf`, `textsOf`, `descriptionsOf`, `editableText`, `isEnabled`, `seesText`, `seesDescription`, `texts`, `sliderValue`, `storedSetting` (`settings.properties`), `clipboardText`. Lookups use keys or literal English text, not `MR.strings`. + +### 0.5.4 Android harness (`android/src/androidTest/java/chat/simplex/app/ui/harness`) + +- **`uiTest { }`** under `createEmptyComposeRule()`: a 300 s watchdog (thread dump to logcat, then `Process.killProcess`; the runner resumes), `bootOnce`, the body, then `finish()` (peers stopped, `Intents.release()` before `ActivityScenario.close()`, `drain`). +- **`bootOnce`:** + - waits at most 180 s for the app's own startup, and rebuilds a fresh database if it has users (15.4 item 10a); + - decrypts it: `apiStopChat`, then `apiStorageEncryption(key → "")`; on failure it keeps the encrypted template and logs the error. decryption succeeds (verified in the final run, 15.4 item 25); + - saves the template to `dataDir/uitest-db-template` (an empty key file means decrypted); + - presets blur 0, `notificationsMode = OFF`, both background-service notices shown, `canAskToEnableNotifications = false`, `privacyProtectScreen = false`; + - `PrefStores.capture` (the two SharedPreferences stores and `themes.yaml`), `ModelSnapshot.capture`. +- **`app(user)`**: `resetForTest` (stop, delete, `PrefStores.restore`, `CommonUiState.clear`, `TestPrefs`, template, init, user, `startChat`, servers from the bridge, `setLastVersionDefault`), then `ActivityScenario.launch(MainActivity)` with Espresso-Intents stubbing external intents. +- **`restart()`**: `restartCore` first, then a poll (60 s) until chat runs with the user loaded, then close and relaunch the activity (15.4 item 10). +- **`poll`**: `advanceTimeBy(48)`, `waitForIdle`, the condition, `sleep(50)`; the deadline extension, last error and message are as on desktop. Failure screenshots go through `UiAutomation` into the app's external files directory, and the runner pulls them. `settle()` is one 500 ms advance. +- **Differences from desktop:** + - `click` uses `performScrollTo`, then centres the node in its `ScrollBy` parent; + - `rightClick` is a long press; `escape()` and `goBack()` press system Back (`Espresso.pressBack`); + - the message field (an `EditText`) is set with Espresso `replaceText`, retried every 2 s for up to 10 s, and read with a `ViewAssertion`; `pressEnter` on it clicks Send, elsewhere it sends the IME action; + - `scrollTo` uses `performScrollToNode`; + - `clipboardText` reads the last recorded intent's `EXTRA_TEXT` (inside `ACTION_CHOOSER` too), then the system clipboard; + - `Timing` logs `UITEST phase N ms: …` and `UITEST wait N ms: …` (waits of 500 ms or more) to logcat; + - there is no known-issue allowlist. + +### 0.5.5 Peers and the bridge + +- **`PeerLines`** (shared) holds the line buffer and all matching. The platform `CliPeer`s implement only `fill()` and `type()`. + - `sends` types the command, then consumes the first matching echo anywhere in the buffer (the command, or the sent item without its `HH:MM ` prefix); + - also `types`, `expects`, `expectsMessage`, `eventually`, `eventuallyMessage`, `eventuallyMatching`, `nextLine`, `contactId`, `pending`; + - line waits default to `LINE_TIMEOUT_MS` = 15 s; a timeout appends the next 10 buffered lines. +- **Desktop `CliPeer`:** + - pty4j, 4000 × 50; `setpriv --pdeathsig KILL sh -c 'stty -echo -icanon; exec …'`; + - args `-d`, `--user-display-name`, `-s `, plus `--reactions`, `--mark-read`, `--show-full-links` from `PeerFlags`; + - `HOME` = the peer directory `/t/peers/`, `TMPDIR` inside it, `LC_ALL=C.UTF-8`, `TERM=xterm-256color`, working directory the repo root; + - the database is copied from `/peer-template` on first start; `cli()` returns at once, and the first use waits at most 60 s for `Current user:` and the prompt; + - typing: write, wait until the input line equals the text (1.5 s unchanged window), else erase with DEL and retype (5 attempts); Enter, and Enter once more if the line still holds the text after 500 ms; + - `VtScreen` is the terminal model. +- **Android `CliPeer`** talks to the host through `Bridge`: TCP `127.0.0.1:7777` (fixed, through `adb reverse`), a 60 s read timeout, one connection per instrumentation process. +- **The bridge** (`UiBridge.kt`, in the desktop harness; the Android runner starts it as a host JVM) owns `TestServers` and desktop `CliPeer`s, serves one client at a time, and stops all peers when the client disconnects. Protocol, one line per command: + - `servers` → `ok `; + - `start ` → `ok` once the peer has started; + - `type ` → `ok`; + - `drain ` → `lines N`, then N lines; + - `stop ` → `ok`; + - any failure → `err `. + + Matching runs device-side in `PeerLines`. Bridge peer directories (`/bridge-peers/-`) are not deleted. + +### 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 an `xftp1` address on base + 2 that **nothing serves**. Any XFTP test fails until P1b. + +### 0.5.7 Helpers and timeouts + +`Helpers.kt`: +- `described`; `goBack()` (desktop clicks `described("Back")`, Android presses system Back); +- `quiesce()`: no API in flight and no event for 1 s, bounded by 30 s; +- `connect(peer)`: a GUI link from `apiAddContact`, joined by the peer; `connectPeers`, `hostGroup`, `CliPeer.createGroup`, `CliPeer.createOneTimeLink`; +- `openContextMenu`: waits for no API in flight, settles, then right-clicks (Android: long press); +- `joinGroupFromList`: opens the row, confirms, waits for the host's "joined" line and for `MemConnected`, then `quiesce`. + +`WhatsNewScreen.VERSION` reads the app's `lastVersion` by reflection. + +| Wait | Desktop | Android | +|---|---|---| +| `poll` default | 10 s, plus up to 60 s while the same API calls stay in flight | same | +| Connections (`CONNECT_TIMEOUT_MS`) | 60 s | 60 s | +| Peer line / peer start | 15 s / 60 s | 15 s / 60 s (in the bridge); bridge socket read 60 s | +| `quiesce` / `drain` | 30 s / 30 s, then stuck items are abandoned | same | +| `restart()` | 30 s | 60 s | +| App startup in `bootOnce` | — | 180 s | +| Hard timeout per test | 240 s: thread dump, `Runtime.halt(3)`; the JVM's remaining tests are lost (15.4 item 20) | 300 s: thread dump, process killed; the runner resumes | +| Gradle task | 12 h | — | + +### 0.5.8 Known-issue policy (desktop) + +`KNOWN_APP_ISSUES` lists "Controller is not initialized" and "database closed" (15.5 item 3). When the scene runner throws: +1. the coroutines-test wrappers (`UncaughtExceptionsBeforeTest`, `DiagnosticCoroutineContextException`) are dropped; +2. if every remaining error matches somewhere in its cause chain and none is an `AssertionError`: + - a test whose body completed passes, logged as `UITEST known app issue, not failing the test`; + - a test whose body never started is retried (at most 3 attempts); + - a test whose body started but did not complete fails; +3. any other error fails the test, with the suppressed errors listed. + +Android has no allowlist. + +### 0.5.9 Runners and Gradle + +**Desktop: `scripts/uitest.sh [gradle args]`.** +- Refuses to start with less than 3000 MB `MemAvailable`. Takes `flock -n build/uitest/runner.lock`. Creates `build/uitest/run-` (with `jvmtmp` and `cache`) and keeps the newest 6 run directories. +- Outside the namespace: + - `cabal build -O0 -f uitest --builddir=dist-uitest exe:simplex-chat exe:uitest-servers`, paths from `cabal list-bin`; + - in the background, the peer database template (`--maintenance -e /v -t 0`, accepted when the output says "no active user in maintenance mode"); + - checks that the native libs are staged; + - `./gradlew -g .gradle/uitest-home --no-daemon :desktop:cmakeBuildAndCopy :common:desktopTestClasses :common:uitestPrepare`, then `--stop`; skipped when a fingerprint (build files, the cpp sources outside `libs`, `ls -l` of the staged libs except `libapp-lib.so`) matches `build/uitest/prepare.key`; + - writes `build/uitest/env.properties` (`libAppLib`, `runDir`, `repoRoot`, `cli`, `servers`). +- Adds `-PuiForks=N`, N = (MemAvailable MB − 2500) / 1000 clamped to 1..4, unless `-PuiForks` is given. +- Inside `unshare -rn --pid --fork --kill-child --mount-proc` (loopback up, multicast route): `./gradlew -g … --offline --no-daemon :common:desktopUiTest -PuiRunDir= -PuiPass=1 `. One pass. Every Gradle call uses `-Pkotlin.compiler.execution.strategy=in-process`. + +**`desktopUiTest`** (`common/build.gradle.kts`; a `KotlinJvmTest` that runs only with `SIMPLEX_UITEST_RUNNER=1`): +- includes `chat/simplex/app/ui/**`, which `desktopTest` excludes; +- `forkEvery` = `-PuiForkEvery`, default **0** (one JVM per fork runs many classes); `maxParallelForks` = `-PuiForks`; 2 GB heap; 12 h timeout; `-PuiAllowEmpty`; +- environment: `XDG_DATA_HOME`, `XDG_CONFIG_HOME` and `FREETYPE_PROPERTIES` removed, `GSETTINGS_BACKEND=memory`, `XDG_CACHE_HOME=/cache`; +- JVM: `en_US`, UTC, `java.io.tmpdir=/jvmtmp`, heap dump on OOM and `-XX:ErrorFile` crash logs into the run directory; headless, skiko, env and IO-parallelism properties; +- `testLogging` with full exceptions and standard streams; results under `common/build/{test-results,reports/tests}/desktopUiTest/pass1`. + +`uitestPrepare` only resolves `desktopTestRuntimeClasspath`. `uitestBridgeClasspath` copies the harness classes to `build/uitest/bridge-classes` and writes `build/uitest/bridge.classpath`. + +**Android: `scripts/uitest-android.sh [am instrument -e …]`.** +- Requires a previous `scripts/uitest.sh` run (for `env.properties`, the CLI and `uitest-servers`) and 4500 MB `MemAvailable`. +- Environment: + - `UITEST_SERIAL`: any adb device; Waydroid is then not used; + - `WD_INSTANCE` (default 2), `WD_OWNER` (default `uitest-$USER`); + - `UITEST_APP_ID` (default `chat.simplex.app.debug`); + - `ADB`, `JAVA_HOME` (defaults: `adb` from `ANDROID_HOME`/`ANDROID_SDK_ROOT` or `PATH`, `java` from `JAVA_HOME` or `PATH`). +- Setup: + - rebuilds the bridge classpath only when one of its 7 sources is newer; + - 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. +- 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`; + - `instrument-.txt`, `instrument.txt`, `logcat.txt` (`logcat -G 16M`), `timings.txt`, `failures/`, `bridge.log`. +- The exit status is non-zero on any −2 or `crashed`, or when there are no results. +- It does **not** run `wd.sh net isolate` (open item). The container keeps internet access during a run, and isolation rests on the local server configuration and on onboarding opening no connection. + +APKs: `ANDROID_TEST=1 bash ~/build/android.sh` (run in the worktree) builds the app APK and publishes the test APK next to it. + +### 0.5.10 How to run + +```sh +cd apps/multiplatform +scripts/uitest.sh # all desktop tests +scripts/uitest.sh --tests '*ChatListRowsTest*' # one class (any Gradle test filter) +scripts/uitest.sh -PuiForks=1 -PuiForkEvery=1 # serial, one JVM per class +ANDROID_TEST=1 bash ~/build/android.sh # app APK + androidTest APK +scripts/uitest-android.sh # all Android tests on Waydroid instance 2 +scripts/uitest-android.sh -e class chat.simplex.app.ui.main.ChatListRowsTest +UITEST_SERIAL= scripts/uitest-android.sh # another adb device +``` + +### 0.5.11 Design items not built yet (P1b+) + +| Design | Not built | +|---|---| +| 4, 5, 9.4 | typed semantics, keys everywhere, key lint: superseded for now by the minimal-app-code rule (13) | +| 6 | the real-time clock target and input-lead correction (the loop advances a fixed 16 ms, Android 48 ms, so composition delays fire late in real time); the end-of-test dialog drain; closing alerts with Escape or by reflection (alerts are only dropped with `hideAllAlerts` at the next reset); the end-of-test `ctrlInitInProgress` wait; `setCatchNonTestRelatedExceptions(false)`, `UiUncaught`, `allowUncaught` and composition-error bookkeeping; JVM poisoning, the `Slow` category, the EDT probe and per-shot allowances (the hard timeout halts the JVM instead) | +| 7.1, 7.1.1 | JUnit categories (`-PuiTags`, `-PuiExcludeTags`, `Smoke`, …); pinned fonts (`FONTCONFIG_FILE`); the `shots`, `shotTolerance`, `pass` and `strict` system properties; Kover and `uitestCoverageMerge`; `uitestPrepare` steps 2–4 (stable and fixture CLI downloads, migration lists) and the migration check; the second (`Multicast`) pass, the empty-run check, screenshot cleanup and coverage merge | +| 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 | `xftp1` (configured but not served), `smp2`, `xftp2`, the names resolver, the control protocol, `restoreBaseline` | +| 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 | +| 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 + +Paths are repo-relative. **C** = `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common`, **D** = `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common`, **A** = `apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common`, **Hs** = `src/Simplex/Chat`. Every `file:line` reference in the plan and the catalogue is to master at `d72f68e6d` (2026-10-10), checked out at `~/wt-uitest-ref`. + +- **Key** — a value of type `UiKey`: a dotted id plus an optional entity argument, e.g. `chatList.row` + `@12`. Rendered as one `testTag` string, `chatList.row[@12]`. +- **Semantics node** — the unit Compose tests find and act on. `clickable`, `Button`, `IconButton`, `Switch`, `Tab`, `DropdownMenuItem` merge their children into one node. +- **Driver** — the object that finds nodes and performs actions, backed by `SkikoComposeUiTest` (the scene runner's receiver, 7.2) in the test JVM. +- **Screen object** — a Kotlin class for one screen whose methods are user actions and whose properties are what the screen shows. +- **Peer** — a non-GUI user in a test: a CLI process driven by its terminal text, as in the Haskell tests (`CliPeer`, also used for remote-desktop mobile hosts). +- **Relay** — a chat relay CLI process in headless mode (`RelayPeer`, 8.1); it has no terminal, only a log, and takes commands only through `run(cmd)`. +- **Sub-area** — one `###` section of a catalogue file (e.g. 01 §7 "1-time link"); the `Area` column inside a section is finer and only labels rows. + +## 1. Goals and non-goals + +Goals: +- Tests that read like the CLI tests and name what the user sees. +- Stable lookups: by key or by `MR.strings` key, never by position or pixel. +- Assertions on typed state (unread count, delivery status, item metadata, sender, verification, incognito, muted). (Deferred by the 2026-10-14 minimal-app-code rule, 13: states are read through the model, visible texts or content descriptions, 0.5.2.) +- Version matrices: the GUI on the current build, peers on the current CLI or a stable release CLI. +- Local runs: headless, no Xvfb, no display, no real network, deterministic waits, failure output with a semantics dump and a screenshot (no test assertion reads pixels; committed screenshots are compared outside the test logic, 7.7). + +Non-goals for this plan: +- Badge display (see the decisions above). +- A remote automation server, tests against real AppImages, and tests with two GUIs. +- CI integration (tests run locally; a CI job can call the same runner script later). +- Android-only UI (catalogue `05-android-only.md`) is a separate effort. The catalogue tests themselves run on Android too: since 2026-10-13 the Android harness shares screens, helpers and test bodies through `common/src/uiTestShared` (13, 0.5.4). +- Pixel assertions inside tests: a test's own checks read semantics only; committed screenshots (7.7) are compared outside the test logic and fail a test only with `-PuiShots=verify`. + +The parts of the app that leave the process are tested up to their boundary: calls up to the browser launch (a `LocalUriHandler` stub records the call link), VLC playback and voice recording behind approved seams (11.2), OS notifications (recorded), file choosers (completed by the test). Tray and window chrome are not rendered. Sections 7.6 and 11 list each surface. + +## 2. Architecture + +(As built: see 0.5. Differs: no `UiDriver` layer, each platform has a `UiScope` with the same methods; no typed semantics; `uitest-servers` runs `smp1` only; no `RelayPeer`.) + +``` +runner script uitest.sh: build + fetch outside, then Gradle inside a loopback-only network namespace + │ +tests (DSL) uiTest { val alice = app("alice"); val bob = cli("bob") ... } // peer version from peerVersion (10.1) + │ +screen objects ChatListScreen, ChatRow, ChatScreen, NewChatSheet, ConnectPlanAlert, UserPickerScreen, ... + │ +UiDriver node(key) / node(text) / nodes(matcher) → UiNode; input; poll; dump + │ skiko test scene (manual clock) + real core over JNI, one GUI per test JVM +app code Modifier.ui(Ui.X.Y), semantics { unread = ... } (keys + typed semantics) +peers CliPeer: CLI binary under a pty, sends/expects on terminal lines (mobile hosts too); RelayPeer: headless relay +servers uitest-servers (SMP, second SMP, XFTP, second XFTP, names resolver), once per test JVM +``` + +## 3. Keys + +### 3.1 Type and modifier + +(As built: see 0.5.2. Differs: the flag is `uiTestHooksEnabled` and also gates `UiIdle`; Android sets it through `initUiTags(context)` in `A/ui/UiTags.android.kt`, called from `SimplexApp`; `Ui` holds only the keys tests use, with no `Ui.Alert.Title`, `Ui.Modal.Back` or `ModalPlacement` local; `described("Back")` replaces `Ui.Modal.Back`.) + +New file `C/ui/Ui.kt` (commonMain). The key code compiles into every build, but tags are emitted only in test and debug builds (decided 2026-10-12: test tags are for testing only): `Modifier.ui` is a no-op unless `uiTagsEnabled` is set, which the desktop harness does in `bootJvm` (the `desktopTest` source set sees `internal` members of `:common`) and, on Android, a new public common function called from `SimplexApp.initMultiplatform()` sets it when the app is debuggable (`ApplicationInfo.FLAG_DEBUGGABLE`, true for the `debug` build type unless `local.properties` sets `debuggable=false`, android/build.gradle.kts:56; `BuildConfig` belongs to the `:android` module, which can't set an `internal` var, and no existing common init has a `Context`); desktop release packages and Android release builds never set it. + +```kotlin +@Immutable +class UiKey(val id: String, val arg: Any? = null) { + val tag: String get() = if (arg == null) id else "$id[$arg]" + fun option(value: Any) = UiKey("$id.option", value) + fun part(name: String) = UiKey("$id.$name", arg) +} + +@Volatile internal var uiTagsEnabled = false // set by the test harness (desktop) or a debug build (Android) +fun Modifier.ui(key: UiKey?): Modifier = if (key == null || !uiTagsEnabled) this else testTag(key.tag) + +object Ui { + object ChatList { + val Bar = UiKey("chatList.bar") // DefaultAppBar: title = Bar.part("title") + val List = UiKey("chatList.list"); val Search = UiKey("chatList.search"); val NewChat = UiKey("chatList.newChat") + val UserPicker = UiKey("chatList.userPicker") + fun Row(chatId: String, remoteHostId: Long? = null) = UiKey("chatList.row", rowArg(chatId, remoteHostId)) + fun RowMenu(chatId: String, action: String, remoteHostId: Long? = null) = UiKey("chatList.row.menu.$action", rowArg(chatId, remoteHostId)) + fun Tag(tag: Any) = UiKey("chatList.tag", tag) + private fun rowArg(chatId: String, rh: Long?) = if (rh == null) chatId else "$rh:$chatId" + } + object Chat { + val Items = UiKey("chat.items") + val Bar = UiKey("chat.bar"); val Info = UiKey("chat.info"); val Banner = UiKey("chat.banner") // title (opens info) = Bar.part("title") + val Message = UiKey("chat.compose"); val Send = UiKey("chat.send"); val Attach = UiKey("chat.attach") + fun Item(itemId: Long) = UiKey("chat.item", itemId) + fun ItemMenu(itemId: Long, action: String) = UiKey("chat.item.menu.$action", itemId) + fun MemberAvatar(groupMemberId: Long) = UiKey("chat.memberAvatar", groupMemberId) + } + enum class AlertSource { Shared, PrivacySensitive } + object Alert { + fun Title(src: AlertSource) = UiKey("alert.title", src) + fun Confirm(src: AlertSource) = UiKey("alert.confirm", src) + fun Dismiss(src: AlertSource) = UiKey("alert.dismiss", src) + fun Option(src: AlertSource, name: String) = UiKey("alert.option.$name", src) + } + object Modal { fun Back(placement: ModalPlacement?) = UiKey("modal.back", placement) } + // ... one nested object per UI area (Search, Screen, NewChat, Share, UserPicker, Settings, Group, …) +} +``` + +- **Typed, hierarchical, compile-checked.** A renamed key breaks the build of the test that uses it. +- **Why not composable function names:** references to `@Composable` functions (`::SendMsgView`) don't compile, and the composable names in `sourceInformation` are stripped or unreliable after inlining. +- **Release cost:** one volatile read per keyed modifier and no semantics property, because release builds never set `uiTagsEnabled` (above); in test and debug builds, one semantics property per keyed node (`testTag` isn't announced by screen readers). +- **Naming rule:** `area.component[.part]` in camelCase. Entity keys always take the core id (`chat.id`, `cItem.id`, `groupMemberId`, `userId`), never a list index, except where no id exists (section 3.4). Lazy lists get a list key too (`Ui.ChatList.List`, `Ui.Chat.Items`), used by `scrollTo` (7.4). +- **Two alert managers.** `AlertManager.shared` and `AlertManager.privacySensitive` both render (`privacySensitive` at C/App.kt:242, only when the user is authorized; `shared` at :255), each showing its last alert (C/views/helpers/AlertManager.kt:432). Alert keys therefore carry the manager. +- **Modal placement.** `ModalManager(placement: ModalPlacement? = null)` (C/views/helpers/ModalView.kt:97): on Android every manager has placement `null`, and the floating terminal renders `ModalView` with no manager (D/DesktopApp.kt:219). The placement is provided as a CompositionLocal (default `null`) in `ModalManager.showInView()` (:201/:217, already composable and already wrapping each modal in a `CompositionLocalProvider`). + +### 3.2 Placement rule + +`Modifier.ui(key)` goes **on the same modifier chain as the `clickable`/`combinedClickable`/`toggleable`** (or on the component that owns the click, such as `ExposedDropdownMenuBox`), before it. A tag on a child of a merged node is hidden from the default (merged) tree, because testTag's merge policy keeps the parent's value (verified in ui 1.8.2). Examples: `SectionItemView` Row (C/views/helpers/Section.kt:213), `SimpleButtonFrame` (C/views/helpers/SimpleButton.kt:90), the bubble in `ChatItemView` (C/views/chat/item/ChatItemView.kt:327). + +### 3.3 Helper parameters (one edit keys many screens) + +Add `uiKey: UiKey? = null` (named `uiKey` because some helpers already have a parameter called `key`, e.g. `PassphraseField(key: MutableState, …)`, C/views/database/DatabaseEncryptionView.kt:317) or a fixed internal key to these shared helpers. Site counts are approximate. + +| Helper | file:line | Sites | Key | +|---|---|---|---| +| `SectionItemView` (+ `WithoutMinPadding`, `LongClickable`, `SpaceBetween`) | C/views/helpers/Section.kt:198/221/236/264 | 274 / 7 / 5 / 28 | param | +| `SectionItemWithValue`, `SectionViewSelectable(Cards)` | Section.kt:287/155/178 | 4 / 3 / 2 | param; options `key.option(value)` | +| `SectionTextFooter` (AnnotatedString overload; the String overload at :314 delegates) | Section.kt:319-328 | ~100 | new `uiKey` param on its `Text`, so link spans in footers (SetSimplexNameView.kt:217) have a keyed ancestor (3.5, 9.4) | +| `InfoRow` (read-only) / `InfoRowEllipsis` (clickable, via `SectionItemViewSpaceBetween`) | Section.kt:370/437 | 64 / 1 | param | +| `SettingsActionItem`, `SettingsActionItemWithContent` | C/views/usersettings/SettingsView.kt:384/393 | 98 / 37 | param | +| `SettingsPreferenceItem`, `PreferenceToggle(WithIcon)` | SettingsView.kt:423/437/453 | 23 / 11 / 6 | param, applied to the Switch (the row has no click) | +| `SharedPreferenceToggle`, `SharedPreferenceToggleWithIcon` (2 overloads) | C/views/usersettings/CallSettings.kt:85/101/131 | 1 / 2 | param on both overloads (`SharedPreferenceRadioButton` :155 has no call sites) | +| `DefaultSwitch` | C/views/helpers/DefaultSwitch.kt:14 | 24 | already has `modifier` | +| `ExposedDropDownSettingRow` → `ExposedDropDownSetting`; `ExposedDropDownSettingWithIcon` | C/views/helpers/ExposedDropDownSettingRow.kt:156 → 25; :89 | 30 + 1 (Android only, CallView.android.kt:361) | key on `ExposedDropdownMenuBox` (:37-38, owns `onExpandedChange`); `DropdownMenuItem` :68 (and :133 in `…WithIcon`) = `key.option(value)` | +| `IntSettingRow`, `TimeoutSettingRow` | C/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt:436/495 | 5 / 6 | param + options | +| `DropdownCustomTimePickerSettingRow` | C/views/helpers/CustomTimePicker.kt:83 | 2 | param | +| `ItemAction` (5 overloads; all context menus) | ChatItemView.kt:1096/1116/1142/1170/1190 | 92 | param (no modifier today) | +| `SimpleButton*` → `SimpleButtonFrame` | SimpleButton.kt:26/54/39/70 → 88 | 23 | param in the frame | +| `NavigationButtonBack/Close/Menu`, `ShareButton` | C/views/helpers/DefaultTopAppBar.kt:128/137/155/146 | ~20 | fixed keys | +| `ModalView` back button | C/views/helpers/ModalView.kt:59-61 | 60 + ~115 `showModal`/`showModalCloseable` | fixed `Ui.Modal.Back(placement)` from the CompositionLocal of 3.1 | +| `DefaultAppBar` title / search | DefaultTopAppBar.kt:24 (:39, :84) | 10 | param `uiKey` (the screen's bar, e.g. `Ui.ChatList.Bar`, `Ui.Chat.Bar`; the chat list and the chat are shown side by side, ChatListView.kt:612, ChatView.kt:1443), title and search = `uiKey.part("title"/"search")`; trailing `buttons` keyed at call sites | +| `AppBarTitle` | C/views/helpers/AppBarTitle.kt:20 | 111 | `Ui.Screen.X` screen-root key, param | +| AlertManager built-in buttons | C/views/helpers/AlertManager.kt:155/159, 196/200, 239/244, 400/408/415, title 441 | ~440 alert calls | fixed `Ui.Alert.*(source)` from the rendering manager (3.1) | +| `TextEditor` / `PlainTextEditor` | C/views/helpers/TextEditor.kt:29/108 | 8 / 2 | `TextEditor` has `modifier`; `PlainTextEditor` gets the param | +| `DefaultBasicTextField`, `DefaultConfigurableTextField`, `PassphraseField` | C/views/helpers/DefaultBasicTextField.kt:30/115 (+ reveal button :189); DatabaseEncryptionView.kt:317 | 9 / 11 | through their `modifier` (applied to the text field; PassphraseField's at :357); reveal = `key.part("reveal")` | +| `PlatformTextField` (compose message field) | C/platform/PlatformTextField.kt:12; desktop actual D/platform/PlatformTextField.desktop.kt:44-58 | 1 | the expect/actual signature gets the param (the desktop actual has no `modifier`); fixed `Ui.Chat.Message` | +| `SearchTextField` | C/views/helpers/SearchTextField.kt:32 (clear :122) | 5 | param `uiKey`, clear = `uiKey.part("clear")`: the chat list passes `Ui.ChatList.Search` (ChatListView.kt:799), the other direct callers their screen's key (GroupChatInfoView.kt:1390, AddGroupMembersView.kt:217, NewChatSheet.kt:494), `DefaultAppBar` its own `uiKey.part("search")`; a fixed key would match twice while the chat list and an end-pane screen are both shown | +| `ProfileNameField` | C/views/WelcomeView.kt:415 | 13 | param | +| `OnboardingActionButton` (expect :187 + 2 actuals, and :190), `TextButtonBelowOnboardingButton` | C/views/onboarding/SimpleXInfo.kt:187/190/223 | 25 / 11 | :190 already has `modifier`; the expect overload and :223 get the param | +| `InfoViewActionButton` | C/views/chat/ChatInfoView.kt:1061 | 13 | param | +| `ActionButton` (2) | C/views/newchat/NewChatSheet.kt:785/828 | 10 | param | +| `OpenChatButton` | C/views/chat/group/GroupMemberInfoView.kt:879 | 3 | param | +| `UserPickerOptionRow`, `UserProfilePickerItem`, `DevicePickerRow` | C/views/chatlist/UserPicker.kt:483/399/493 | few | param (entity: userId / remoteHostId) | +| `ChatListNavLinkLayout` (expect + 2 actuals) | C/views/chatlist/ChatListNavLinkView.kt:1027; android actual :14 (combinedClickable :26); desktop actual :28 (combinedClickable :39) | 8 | param, passed `Ui.ChatList.Row(chat.id, chat.remoteHostId)` | +| `ChatItemView` bubble | ChatItemView.kt:69 (:327) | 4 | `Ui.Chat.Item(cItem.id)` | +| `SendMsgButton` | C/views/chat/SendMsgView.kt:452 (:466) | 3 (:136/:246/:256) | fixed `Ui.Chat.Send` | +| `SelectableCard` | C/views/onboarding/SetNotificationsMode.kt:68/73 | 5 | `key.option(value)` | + +Exception: `showAlertDialogButtons(Column)` takes caller-built buttons (44 calls in 25 files, built from `SectionItemView`, `TextButton` and small helper buttons; e.g. C/views/newchat/ConnectPlan.kt:463, :567, :599). Those are keyed at the call site with `Ui.Alert.Option(source, name)`. + +### 3.4 Entity keys + +| Key | Emitted at | Id | +|---|---|---| +| `ChatList.Row(chatId, remoteHostId)` + row menu | C/views/chatlist/ChatListView.kt:1099 → ChatListNavLinkView.kt:67-164; menu :278-640 | `chat.id` (`@12`, `#5`) and `chat.remoteHostId` | +| `NewChat.Contact(chatId)` | NewChatSheet.kt:341/430/752 → C/views/contacts/ContactListNavView.kt:47/107 | `chat.id` (also add LazyList keys there) | +| `Share.Row(chatId)` | C/views/chatlist/ShareListView.kt:213 | `chat.id` (add LazyList key) | +| `ChatList.Tag(id)` | ChatListView.kt:1295-1318; C/views/chatlist/TagListView.kt:100/115 | `PresetTagKind` / `chatTagId` | +| `Chat.Item(itemId)` (+ reactions, select) | C/views/chat/ChatView.kt:2398 → :1922 → ChatItemView.kt:327; reactions :143/195/363; select overlay ChatView.kt:2236 | `cItem.id`; merged groups `merged.newest().item.id` | +| `Chat.MemberAvatar(groupMemberId)` | ChatView.kt:2086 | `groupMemberId` | +| Members (info list, channel members, support, mentions, relays, delivery) | C/views/chat/group/GroupChatInfoView.kt:778/1099; ChannelMembersView.kt:115; MemberSupportView.kt:131; C/views/chat/group/GroupMentions.kt:226; ChannelRelaysView.kt:79; C/views/chat/ChatItemInfoView.kt:430 | `groupMemberId` | +| Add-members contacts | C/views/chat/group/AddGroupMembersView.kt:310/321 | `contact.apiId` | +| Relays | C/views/newchat/AddChannelView.kt:452/545; AddGroupRelayView.kt:138; C/views/chat/ComposeView.kt:1961/2070; chat-relay lists ProtocolServersView.kt:92, OperatorView.kt:247 | `groupRelayId` (AddChannelView), `relayId` (AddGroupRelayView), `groupMemberId` (ComposeView :2070 iterates `GroupMember`), relay id or address (settings lists) | +| User profiles | UserPicker.kt:399 (android :55/81, desktop :47-54); C/views/usersettings/UserProfilesView.kt:177; ComposeContextProfilePickerView.kt:260; C/views/newchat/NewChatView.kt:443/452 | `userId` (add LazyList keys at NewChatView.kt:443/452) | +| Remote hosts / controllers | UserPicker.kt:533/493; C/views/remote/ConnectDesktopView.kt:420 | `remoteHostId` / `remoteCtrlId` | +| Operators | C/views/usersettings/networkAndServers/NetworkAndServers.kt:215/674; onboarding ChooseServerOperators.kt:211 | `operatorId` | +| Servers | ProtocolServersView.kt:105/139; OperatorView.kt:335-452 | server address (ids are null for unsaved servers) | +| Commands, versions, wallpapers, tabs, image previews | CommandsMenuView.kt:231; ChatItemInfoView.kt:327; Appearance.kt:398; NewChatView.kt:136; ComposeImageView.kt:35 | command label (menus have no keyword) / version index / enum / index (add LazyList keys at Appearance.kt:398 and ComposeImageView.kt:35) | +| Badges ledger, pay, level | C/views/badges/BadgesLedgerView.kt:83, BadgesPayView.kt:148, BadgesYourLevelView.kt:125 (pay and level are unreachable today, 9.4) | `entryId` / period / level | + +### 3.5 Controls that need more than a click on a keyed node + +- **Links inside messages.** `ClickableText` (C/views/chat/item/TextItemView.kt:426) handles clicks with `pointerInput`, so links have no click semantics. `UiNode.clickAtTextRange(range)` reads the text node's `GetTextLayoutResult` (in the unmerged tree), computes the bounding box of the link's characters and clicks there. No app change. Texts built with `withLink(LinkAnnotation.Url…)` (e.g. the Wefunder link, WhatsNewView.kt:1210; SetSimplexNameView.kt:217) are different: Compose 1.8.2 gives each link span its own child node with `OnClick` and `LinkTestMarker`, which no `Modifier.ui` reaches, so tests click it as the `LinkTestMarker` child of the keyed `Text` with `performClick`; the Wefunder `Text` gets its key at its call site, and footer links get theirs through `SectionTextFooter`'s `uiKey` (3.3). +- **Voice record press-and-hold** (SendMsgView.kt:418): hold-versus-tap is decided by wall-clock time (`firstTapTime + 1000L < System.currentTimeMillis()`, GestureDetector.kt:265-267), so `UiNode.hold(1.2.seconds)` presses on the node (`performMouseInput { moveTo(center); press() }`; a new injection otherwise presses where the previous one left the pointer, InputDispatcher.kt:145-153), waits 1.2 s of real time through the polling loop of section 6 (so the Compose clock advances and the press interaction progresses; a plain `Thread.sleep` would freeze composition), and releases (with the recorder seam of 11.2). +- **Long-press and right-click menus:** `longClick()` / `rightClick()` on the keyed row. +- **Keyboard:** `press(key, modifiers)` sends key events to the focused node (Enter, Shift+Enter, Up, Escape, Ctrl+C/V, digits, Backspace). +- **Drag and hover:** `dragTo(other)` (text selection between items, list reordering) and `hover()` (hover-only buttons) use `performMouseInput`. +- The selection toolbars (C/views/chat/group/GroupMembersToolbar.kt, C/views/chat/SelectableChatItemToolbars.kt) use plain `IconButton`s (GroupMembersToolbar.kt:62/71/80, SelectableChatItemToolbars.kt:95/104/113); the `pointerInput` at :54/:87 only swallows background gestures. They are keyed like any button. + +## 4. Typed semantics + +(As built: not built. Superseded for now by the 2026-10-14 minimal-app-code rule (13); tests read these states through the model, visible texts or content descriptions, 0.5.2.) + +New file `C/ui/UiSemantics.kt` (its properties are set only when `uiTagsEnabled`, like the tags of 3.1): + +```kotlin +val UnreadKey = SemanticsPropertyKey("SimplexUnread") +var SemanticsPropertyReceiver.unread by UnreadKey +data class UiUnread(val count: Int, val mentions: Int, val unreadChat: Boolean) + +val ItemMetaKey = SemanticsPropertyKey("SimplexItemMeta") +data class UiItemMeta(val status: String, val sentViaProxy: Boolean?, val encrypted: Boolean?, val signed: Boolean?, val timedTtl: Int?, val edited: Boolean, val live: Boolean) + +val SenderKey = SemanticsPropertyKey("SimplexSender") // display name of a group item's sender, on every item, not only the first of a run +// likewise: VerifiedKey, IncognitoKey, MutedKey(ntfsMode), FavoriteKey, SelectedKey, FileStatusKey(CIFileStatus kind), SubStatusKey + +val VolatileKey = SemanticsPropertyKey("SimplexVolatile", mergePolicy = { parent, _ -> parent }) // content that changes every run (durations, QR codes, links, codes; displayed times come from the display clock); masked in screenshot comparison (7.7); set on the volatile child and not merged into its row or item +fun Modifier.uiVolatile() = if (!uiTagsEnabled) this else semantics { set(VolatileKey, true) } +``` + +Each property is set on the **merged node that carries the key**, not on the child icon, so one lookup returns the row or item with all its state: + +| State | Rendered at | Set on | +|---|---|---| +| Unread / mentions / unread mark | C/views/chatlist/ChatPreviewView.kt:461-500, `UnreadBadge` :591; UserPicker.kt:639 | `ChatList.Row`, user rows | +| Muted, favorite | ChatPreviewView.kt:502-521 | `ChatList.Row` | +| Incognito | ChatPreviewView.kt:557, ChatInfoImage.kt:59, ContactPreviewView.kt:145 | rows, info headers | +| Verified | ChatPreviewView.kt:97; ChatView.kt:1742; ChatInfoView.kt:737; GroupMemberInfoView.kt:775; VerifyCodeView.kt:67-69 (in the `Row` at :66) | rows, title, info headers, the security code screen's status `Row` (VerifyCodeView.kt:66-72; it has no click, so it gets its own read-only key `Ui.VerifyCode.status` with `mergeDescendants`, area K3) | +| Item status, sent via proxy, encrypted, signed, timed, edited | C/views/chat/item/CIMetaView.kt:69-125 | `Chat.Item` (`ItemMetaKey`) | +| Live message | C/views/chat/item/FramedItemView.kt:287, TextItemView.kt:176-183 | `Chat.Item` (`ItemMetaKey.live`) | +| Group sender | ChatView.kt:2039/2097 (drawn outside the bubble, only on the first item of a run) | `Chat.Item` (`SenderKey`) | +| File status | CIFileView.kt:139-177, CIImageView.kt:73-98, CIVideoView.kt:498-561 | `Chat.Item` | +| Subscription status | C/views/helpers/SubscriptionStatusIcon.kt:12, ChatView.kt:1635 | indicator node | +| Selected / current | `ChatListNavLinkLayout(selectedChat)`, UserPicker.kt:425, TagListView.kt:153 | row | + +Matchers in the test library are `SemanticsMatcher`s over these properties: `unread(n)`, `itemStatus("sent")` (the `UiItemMeta.status` string; e.g. `sent`, `delivered`, `error`), `edited()`, `sender(name)`, `verified()`, `incognito()`, `hasTagPrefix(prefix)`, ... + +## 5. Rollout of keys ("everywhere") + +(As built: not followed. Superseded for now by the 2026-10-14 minimal-app-code rule (13): keys only on elements tests act on or must read, 0.5.2.) + +### 5.1 Size + +The helpers of 3.3 have about 1,000 call sites (the inventory counted 776 interactive ones, excluding `InfoRow` and `AppBarTitle`). Ad-hoc `clickable`/`IconButton`/`TextButton`/`Button` sites: about 300 in about 100 files of commonMain and desktopMain. In total about 190 files are touched. Keying the helpers reaches about 70% of interaction sites. + +### 5.2 Key areas + +This is a map of where keys go, not a PR sequence; PRs follow the phases of section 12 (one PR per catalogue sub-area, carrying the keys that sub-area's tests need). The file counts are rough and overlap. + +| Area | Scope | Files (approx.) | +|---|---|---| +| K0 | `Ui.kt`, `UiSemantics.kt`, `Modifier.ui`, `UiIdle.kt` and its hooks (6; pending confirmation, 13); every helper parameter of 3.3; fixed keys in AlertManager, ModalView (+ placement local), the DefaultTopAppBar navigation and share buttons, PlatformTextField, `SendMsgButton` (`Ui.Chat.Send`); `uiKey` parameters on DefaultAppBar and SearchTextField; `uiVolatile()` (7.7) on QRCode, the invitation/address link and security-code texts, ServersSummaryView statistics, call and voice durations, server addresses with ports, link-preview URLs, multi-relay lists, incognito profile names, the remote pairing port field and session codes (desktop and Android), the pasted desktop invitation (Android), the chat console's line list, the "Message queue info" alert (a volatile-text parameter on `showAlertMsg`), the chat-relay address editor, the live-message send-button pulse, the typing indicator and animated images; the display-clock seam (11.2) in ChatModel.kt's time formatters, `FloatingDate`, the grouping check and the SimpleX-name sale countdown | ~30 | +| K1 | Chat list, user picker, new chat sheet, paste link, connect plan alerts; unread/muted/incognito semantics on rows | ~15 | +| K2 | Chat view: toolbar (ChatView.kt:1194-1433), items + item menus, compose/send/attach (ComposeView, SendMsgView, Compose*View, ContextItemView, CommandsMenuView, GroupMentions, SelectableChatItemToolbars), item info; item meta, sender and file semantics | ~30 | +| K3 | Chat/contact/group/member info, member lists, add members, preferences, verify code, group/channel creation and links, relays | ~25 | +| K4 | Address screens, settings tree (SettingsView, profiles, privacy, appearance, network and servers, operators, calls, developer, database, migration), badges screens | ~60 | +| K5 | Onboarding (SimpleXInfo, WelcomeView, ChooseServerOperators, YourNetwork, WhatsNewView), remote desktop link, misc | ~20 | + +Ad-hoc sites with many raw clickables to key directly (from the inventory): ChatListView.kt (20: FAB 480, toolbar 531/547/586/706/750, tag chips 1212-1474), ChatView.kt toolbar and FABs (1242-1433, 2850/3266/3284), ChatItemView.kt (195/270/363), item views (CIVoiceView 267/305/351, CIVideoView 322-465, CIImageView 112-147, CIFileView 193, FramedItemView 126/294/405), SendMsgView.kt (316, 376-418, 439, 466, 504), ComposeView.kt (1285, 1319, 1562, 2119/2144), WhatsNewView.kt (13 sites), Appearance.kt (9), UserProfileView.kt (6), NewChatView.kt (136, 218-253, 589/612, 719, 751/758), OperatorView.kt (528-858), YourNetwork.kt (`ConfigureRoutersButton`, :172-177). + +## 6. Synchronization (no sleeps, no hangs) + +(As built: see 0.5.2–0.5.4, 0.5.7, 0.5.8. Differs: each poll iteration advances a fixed 16 ms (Android 48 ms) with no real-time target or input-lead correction, so composition delays fire late in real time; the `sendCmd` item wraps the `withContext(Dispatchers.IO)` block instead of sitting inside it; no end-of-test dialog drain, no Escape or reflection closing of alerts (they are dropped by `hideAllAlerts` at the next reset), no change to the coroutines-test collector and no `UiUncaught`; known app errors follow 0.5.8; the hard timeout is 240 s then `Runtime.halt(3)` (Android 300 s, process killed, runner resumes), with no poisoning, `Slow` category or EDT probe.) + +**Manual clock, one polling loop.** Each test runs with `mainClock.autoAdvance = false`. The driver has one loop, `poll(what, timeout) { condition }`: sleep about 16 ms of real time, advance the Compose clock towards real time, evaluate the condition; repeat until it holds or the deadline passes. Every lookup, assertion and peer `expects` goes through it, and so does every wait inside an input action (`hold`, 3.5). The only fixed wait is `wait(d)` (7.4), the same loop running for `d` of real time with no condition, used only where a catalogue row names the interval (for example "retry more than 3 s after the failure"). + +- **Clock target.** `advanceTimeBy` rounds up to whole 16 ms frames (AbstractMainTestClock.kt:44) and input injection advances the clock as well, so advancing by each iteration's elapsed time would run the clock ahead of real time (up to about 2×). The loop instead keeps `target = clockAtTestStart + realTimeSinceTestStart` and advances by `target - mainClock.currentTime` when that is positive and otherwise by 0, with `ignoreFrameDuration = true` (an advance by 0 still runs the test scheduler's current tasks, which with the `StandardTestDispatcher`, 14, run inside `advanceScheduler` — a render runs the scene's own flush queue — and the closing `runTest`; the advance renders no frame); input injection advances the clock by each event's time (InputDispatcher.skiko.kt:246-259; a `longClick` advances the long-press timeout + 100 ms, MouseInjectionScope.kt:395), so after each input action the driver adds the clock's lead over the loop's target (`mainClock.currentTime - target`, which excludes what settle loops already added) to `clockAtTestStart`, as screenshot settle loops do for what they advance (7.7) — otherwise no frame renders until real time catches up (measured: state written after a `longClick` appeared after about 0.6 s instead of 17–21 ms); virtual time may lag the target but never leads it after these corrections (input leads: about 60–76 ms per click, 50 ms per key press, 600 ms per `longClick`; `performTextInput` doesn't move the clock): a settle loop advances the clock by whole seconds and then adds what it advanced to `clockAtTestStart`, so the loop continues from there instead of freezing until real time catches up, and from then on runs that far (up to about 11 s per shot) ahead of real time, as it does by the input leads added after each action; composition delays pending at a shot therefore fire early. Each advance is capped (e.g. 100 ms per iteration), so if rendering a frame costs more than real time the clock lags instead of starving the EDT (P1 measures the render cost, 14). + +- The app's work outside Compose (core threads, receiver loop, EDT jobs, `withBGApi` executor) runs in real time anyway; advancing the clock by the real elapsed time keeps composition-scoped delays and animations (the live-message update loop `delay(3000)`, SendMsgView.kt:545-550; toast timers; the 1 s status polls) in step with it. +- With `autoAdvance = false`, Compose's own `waitForIdle` returns as soon as layout is done (it ignores idling resources and infinite animations), so no animation loop can block a test — neither indeterminate spinners nor the finite animations restarted in a loop (SendMsgView.kt:534-544), nor the `delay` loops (TextItemView.kt:176-183, the call timer ChatView.kt:1264-1268). Progress screens can be asserted while their command runs. +- The EDT (`withApi`, `withContext(Dispatchers.Main)`; on desktop `Dispatchers.Main` is kotlinx-coroutines-swing, which posts with `invokeLater`) is not part of the Compose clock; the polling loop simply waits for the condition. + +**`UiIdle` (busy tracking).** A commonMain object, always on, records each in-flight item with its start time: +- core commands in `ChatController.sendCmd` (C/model/SimpleXAPI.kt:936-958), added before and removed in `finally`, both inside its `withContext(Dispatchers.IO)` block (:939-957): a composition coroutine's continuation dispatched after the scene's closing `runTest` never runs with the `StandardTestDispatcher` (14), so a `finally` outside that block could leave the item in flight; the periodic status polls (`GetAgentSubsTotal`, ChatListView.kt:683-692; `GetAgentServersSummary`, ServersSummaryView.kt:706-722) are excluded by command type, not by the `log` flag (user commands such as badge redemption also use `log = false`, SimpleXAPI.kt:580); +- events in `startReceiver` (SimpleXAPI.kt:782-831): added when `recvMsg` returns an event (:805), removed in `finally` after `processReceivedMsg` and `trySend` (which run inside `withTimeoutOrNull`, :808-811, with exceptions caught at :823-828); processed events are also counted, with the time of the last one, and items started and finished are counted, for the screenshot quiet wait and settle check (7.7); +- jobs started by `withBGApi`/`withLongRunningApi`/`withApi` (C/views/helpers/Utils.kt:34-49), added on launch and removed on completion. `withLongRunningApi` gets a parameter `idle: Boolean = true`; jobs that never end, or that wait long and are cancelled by the reset, pass `idle = false`: `PopChatCollector.init` (C/model/ChatModel.kt:827; one collector per `ChatsContext`) and the app-update checker `createUpdateJob` (D/views/helpers/AppUpdater.kt:170-176, which re-launches itself after `delay(24 h)`; its `withLongRunningApi` call at :171 is the one site to mark; reached only through `setupUpdateChecker()`, which desktop `main` calls and `bootJvm` doesn't, so the mark only guards a test that starts it), and the agent-error alert timer of `ProcessedErrors` (C/views/helpers/ProcessedErrors.kt:19, which can wait up to 60 s and is cancelled by the reset, 7.3). + +Coroutines launched outside these helpers are not tracked (e.g. the 1 s delayed secondary-chat close, SimpleXAPI.kt:3271-3277, attachment processing, ComposeView.kt:299/310/457, and the file dialogs awaited in composition-scoped coroutines: the attach pickers, ComposeView.desktop.kt:24-38, and the QR save's first dialog, QRCode.kt:91-94); waits on their effects are ordinary polled assertions. + +It is used in four ways: +- an assertion's deadline (default 10 s) is extended while any item that was already in flight at the original deadline is still in flight, up to the original deadline + 60 s, so legitimately long commands (export of a 1 GB file, a 30 s SOCKS timeout) don't make waits expire while a stream of short receiver events can't keep a failing wait alive; tests that wait longer pass an explicit timeout; +- `drain()` waits until nothing is in flight (at most 30 s) before a reset; items still in flight when it gives up are listed in the log and marked abandoned, and abandoned items are ignored from then on by every use of "in flight" (later drains, the deadline extension, the end-of-test dialog poll, the screenshot quiet wait and settle check, 7.7), so a stuck job costs one reset, not every reset or shot in the JVM; +- the failure report lists what was in flight; +- screenshots wait for it to be quiet (nothing in flight, no event processed for 1 s) before settling, and the settle check requires nothing in flight at the second of two equal captures and nothing started, finished or processed between them (7.7). + +**End of each test.** After a passing body of a catalogue test, `uiTest` first drains file dialogs in passes of at most 5: each pass runs `waitForIdle`, then polls (at most 10 s, like the quiet wait of 7.7) until some `DialogState` is pending or UiIdle has nothing in flight, then completes every pending `DialogState` with an empty result (as below); it stops after a pass that completed none (measured with the QR flow ending after `respondSave()`: passes completed 1 then 0, and the end-of-test shot was quiet and settled, 10 of 10; without the poll the first pass completed none, the second dialog opened afterwards, and the shot was `unquiet` and `unsettled`, 10 of 10). The poll is needed because a dialog the body answered last can open the next one only after a `waitForIdle` resumes its waiter, and then from a `Dispatchers.Default` job (e.g. `respondSave()` on the QR save's folder dialog, 7.6: `shareFile` runs in `withLongRunningApi`, Utils.kt:46-49, Share.desktop.kt:26); the drain's own empty results open no further dialog (`saveTempImageUncompressed` returns null, Utils.desktop.kt:240-241, and QRCode.kt:95-96 shares only a file). A dialog awaited from a tracked job keeps that job in flight (`withLongRunningApi`, e.g. CIFileView.kt:109-117), so 7.7's quiet wait and settle check could never succeed while it is open; the attach pickers (ComposeView.desktop.kt:24-38) and the QR save's first dialog (QRCode.kt:91-94) wait in composition-scoped coroutines, which aren't tracked. File choosers are native windows that the shot doesn't show either way. Then the end-of-test screenshot is captured (7.7; not in `REALTIME` tests or with `-PuiShots=off`), and written only once the test has passed. Then, while the scene still exists, `uiTest` (in `finally`, so also after a failing body) closes what would otherwise leave coroutines waiting forever: it completes every pending `DialogState` with an empty result (completing the captured deferred, so a dialog that completes in between causes no `NullPointerException`) (null, or an empty list for `openMultipleDialog`, DesktopApp.kt:274; file choosers, 7.6; `clearTransientUi`, 7.3), then sends Escape to each dialog root until the rendered alert managers show no alert (alerts in a manager that isn't rendered go to the reflection path below) (at most 10 tries), which runs each alert's `onDismissRequest` and so completes the deferreds and continuations behind it (e.g. "Start chat?", `showStartChatAfterRestartAlert`, Core.kt:226-235; the retry alert of `sendCmdWithRetry`, SimpleXAPI.kt:842-869). `hideAllAlerts` alone only drops the composables (AlertManager.kt:48-50) and would leave `initChatController` waiting with `ctrlInitInProgress` set. An alert that remains after the tries is reported and fails the test. After the alerts are closed (by Escape or by the reflection path below), a plain loop — not `poll` — waits at most 10 s, as the reset does (7.2), for `ctrlInitInProgress` to clear (closing "Start chat?" only completes its deferred; initialisation then still loads the user's data before clearing the flag, Core.kt:170-188); if it is still set then, the JVM is poisoned (below). + +**Uncaught errors in app coroutines.** The scene runner (`runInternalSkikoComposeUiTest`, 7.2, 14; `runDesktopComposeUiTest` goes through the same `withScene`) ends with `testScope.runTest { }` (`TestScope` at ComposeUiTest.skiko.kt:155, `runTest` at :201), and kotlinx-coroutines-test 1.8.0 (the version ui-test-desktop 1.8.2 brings) from the first `runTest` on keeps a JVM-wide exception collector enabled (`catchNonTestRelatedExceptions` is `true`): an unhandled error in any coroutine — the app's `withBGApi`/`withLongRunningApi`/`withApi` jobs catch nothing (C/views/helpers/Utils.kt:36-49), and about 36 API wrappers in SimpleXAPI.kt throw, as does `sendCmd` while the controller is null (:937) — is queued and thrown by the next `runTest`, from `withScene`'s `finally` — the same test's when it happens before its scene closes (body or end-of-test cleanup; as `UncaughtExceptionsBeforeTest`, replacing the test's own assertion error), the following test's when it happens after (reset). `bootJvm` therefore turns the collector off (`TestScopeKt.setCatchNonTestRelatedExceptions(false)`, public in the bytecode but internal in Kotlin, so called by reflection; it must run before any `runTest`, since once the collector is enabled it stays on and a later change makes every `runTest` fail its own check) and installs its own `Thread.setDefaultUncaughtExceptionHandler` (the desktop app installs none: `registerGlobalErrorHandler`, C/platform/UI.kt:20-22, is called only on Android, and window crashes go to `showApp`'s `WindowExceptionHandler`, D/DesktopApp.kt:42-43, which isn't rendered here). The handler records each error with its phase; where the `UiIdle` launch hook exists (6), an error from a job is attributed to the test and phase that launched it, so a job abandoned by `drain` can't fail a later test. An error during the test body fails that test at its end (its own assertion error, if any, stays the cause and the recorded errors are added as suppressed exceptions; a test that expects one wraps the step in `allowUncaught(T::class) { … }`, 9.2, which polls (6) until an error of type `T` is recorded, consumes it, and fails if none arrives — the error usually comes from a job on another thread after the click returns); an error during a reset, a restart or the end of a test is written to the run log (and to the failure report if the test fails) without failing any test. + +Errors in composition-scoped coroutines (`LaunchedEffect`, `rememberCoroutineScope().launch`, e.g. VersionInfoView.kt:29-31 calls `sendCmd`, which throws while the controller is null) don't reach that handler: they go to the scene's own handler and are rethrown by the next `waitForIdle` (ComposeUiTest.skiko.kt:270-279), behind every node lookup (`getRoots`, :378-380), and by `withScene`'s `finally` (:202). `throwUncaught` clears what it throws (UncaughtExceptionHandler.kt:72-79), so whoever catches one owns it. They always fail the test, whatever phase they surface in: each `poll` iteration first calls `waitForIdle()` on its own and treats any throwable from it, except `InterruptedException` (the watchdog's, rethrown), as a composition error (so one that happens to be an `AssertionError` is not mistaken for a matcher failure), fails at once if the test has a recorded composition error (a `restartCore` that kept one goes on with a scene that no longer recomposes), and only then evaluates the lookup through `onAllNodes(…).fetchSemanticsNodes()` with its own counting (so anything thrown out of the fetch's own wait for idle is likewise a composition error, not a matcher failure), retrying its own matcher failures and the documented transient states (`scrollTo` waits until the list's `IndexForKey(key) >= 0` before `performScrollToKey`, Actions.kt:197-200; `UiNode.get`/`assert`/`child` evaluate their matchers inside the lookup's transform on the EDT, not on the test thread, Assertions.kt:258-259); each end-of-test step and `restartCore`'s waits for idle (including the one inside `setContent`, ComposeUiTest.skiko.kt:338) catch a `Throwable` from it, rethrow `InterruptedException` (the watchdog's), keep anything else as a composition error and continue; after the scene runner returns, `uiTest` fails the test with the first composition error, or, if the body has its own throwable, keeps that as the cause and adds the composition errors (and a different throwable the scene runner ended with) as suppressed. A failed effect cancels the scene's recomposer job (the recomposer's effect job is a child of it, not a `SupervisorJob`), so after a composition error the dialogs no longer recompose and Escape would keep hitting the same stale dialog root: the end-of-test step then completes the remaining alerts by reflection instead of Escape: each entry of `AlertManager`'s `alertViews` (AlertManager.kt:37) is a `ComposableLambdaImpl` whose `_block` is the show function's lambda, and the harness invokes the dismiss callback that lambda captured — `$onDismissRequest` for `showAlertDialog` (which covers "Start chat?", Core.kt:226-235, and the retry alert, SimpleXAPI.kt:880-888), `showAlertDialogStacked` and `showAlertDialogButtonsColumn`, `$onDismiss` for `showOpenChatAlert`, none for `showAlertDialogButtons`, `showAlertMsg` and `showAlertMsgWithProgress`, whose dismissal only hides — and then drops the entries with `hideAllAlerts`. The field names are compiler-generated, so P1's harness tests check them. The same path is used for every alert in a manager that isn't rendered when the test ends, not only after a composition error: `AlertManager.privacySensitive` while the app is locked has no dialog root (App.kt:240-242). The wait for `ctrlInitInProgress` described under "End of each test" follows. The captured callbacks are invoked on the EDT, as `clearTransientUi` is, since they write Compose state. + +**Hard timeout and poisoning.** `uiTest` checks a poison flag on entry, before the scene runner (whose scene setup and teardown run on the EDT with `invokeAndWait`), and runs the scene runner on a dedicated daemon thread that the JUnit thread joins with a deadline: 2 min (10 min for tests in the `Slow` category), plus 28 s for each screenshot the test takes (10 s quiet wait plus 11 settle steps of about 1.4 s, the steps about 12% slower with the Kover agent attached, 7.8, measured on a toy project; P1 measures the step on the app and adjusts this), plus 10 s for each end-of-test drain pass that waits (6, "End of each test"; normally only the last pass waits, and it waits its full bound only while something stays in flight with no dialog opening, e.g. a long export) — the JUnit thread joins in slices and recomputes the deadline from a shot counter the body increments when each shot starts (a shot costs up to 10 s of quiet wait plus up to 11 settle steps — one before focus is cleared, then at most 10 — of about 1.4 s each on an animated scene, 7.7). When the deadline passes the JUnit thread first probes the EDT (`invokeLater` plus a 5 s latch), then writes the failure report while the scene is still as the test left it: the non-UI parts (peer tails, `UiIdle` list, command log) always; the semantics dump and screenshot only if the EDT probe succeeded, built on a helper thread with its own 20 s timeout, inside `runOnUiThread { … }` (the body thread can still be rendering on the EDT — input injection advances the clock itself, InputDispatcher.skiko.kt:246-259, and every node lookup renders a pending layout, ComposeUiTest.skiko.kt:273-279, 378-380 — while `captureToImage` snapshots on the calling thread, :356-358, so a capture off the EDT can catch a cleared surface; measured: 26 of 150 captures blank off the EDT, 0 of 150 inside `runOnUiThread`, which runs inline on the EDT without deadlock), which records a composition error that its `waitForIdle` surfaces instead of dropping it (`printToString` and `captureToImage` call `waitForIdle`, which renders through `invokeAndWait`, ComposeUiTest.skiko.kt:356-358/:378-380, and would hang the JUnit thread on a stuck EDT). Only then does it interrupt the body thread (the interrupt unwinds through the end-of-test cleanup and closes the scene, ComposeUiTest.skiko.kt:196-203). If the body thread is still alive 15 s after the interrupt, or the probe fails, or a reset's 10 s wait for `ctrlInitInProgress` expires (7.2), or `changingActiveUserMutex` is still held after `drain()` (7.3), or `ctrlInitInProgress` is still set 10 s after the end-of-test alert completion (above), or the staged core's migrations differ from the current CLI's (7.1), the JVM is *poisoned*: every later `uiTest` in it fails at entry with "JVM poisoned by " without touching the EDT, so the rest of the class fails fast instead of hanging on a thread stuck in JNI or on the EDT. The Gradle task also has a `timeout` as a last backstop. + +## 7. In-process driver + +### 7.1 Gradle + +(As built: see 0.5.9. Differs: `forkEvery` defaults to 0; no categories, pinned fonts, `shots`/`shotTolerance`/`pass`/`strict` properties or Kover; `uitestPrepare` only resolves `desktopTestRuntimeClasspath`, and `env.properties` (5 keys) is written by the runner; no migration check. Added: `kotlin.srcDir("src/uiTestShared/kotlin")`, heap dumps and `-XX:ErrorFile` crash logs in the run directory, `testLogging` with standard streams, and the `uitestBridgeClasspath` task for the Android bridge.) + +In `apps/multiplatform/common/build.gradle.kts` the existing `val desktopTest by getting` (line 149) is replaced by: + +```kotlin +val desktopTest by getting { + languageSettings.optIn("androidx.compose.ui.test.ExperimentalTestApi") // error-level opt-ins of the scene runner (7.2) + languageSettings.optIn("androidx.compose.ui.test.InternalTestApi") // runInternalSkikoComposeUiTest (14) + languageSettings.optIn("androidx.compose.ui.InternalComposeUiApi") // its semanticsOwnerListener parameter type, PlatformContext (ComposeUiTest.skiko.kt:91); set module-wide so no test file needs its own @OptIn + dependencies { + implementation(compose.desktop.uiTestJUnit4) // the skiko test scene runner (ui-test-desktop 1.8.2, downloaded into the dedicated Gradle home by the outside step, 7.1.1) + implementation(compose.desktop.currentOs) // skiko native runtime + implementation("org.jetbrains.pty4j:pty4j:0.13.13") // pinned: 0.14.x needs kotlin-stdlib 2.4; 0.13.13 brings jna 5.14.0, slf4j-api 2.0.13 + // and raises kotlin-stdlib to 2.1.21 in the desktopTest classpath only (harmless) + } +} +``` + +and the test tasks: + +```kotlin +tasks.named("desktopTest") { exclude("chat/simplex/app/ui/**") } // existing unit tests unchanged + +tasks.register("uitestCoverageMerge") { /* merges the /coverage/pass.ic files that exist (one or two) through Kover's reporter and writes the HTML/XML report and summary; run by the runner after both passes (7.8) */ } + +tasks.register("uitestPrepare") { // run outside the namespace (7.1.1) + // 1. resolve desktopTestRuntimeClasspath, so the offline run inside the namespace finds every runtime-only artifact + // 2. download the stable CLI (-PuiStableTag) and fixture CLIs (-PuiFixtureTags), verify their published SHA-256 (mandatory) and set the executable bit + // 2b. resolve Kover's koverJvmAgent and koverJvmReporter configurations (7.8), so the offline run and the merge find them + // 3. run the current CLI (built before Gradle, 7.1.1) on a scratch database and record its chat and agent migration lists (7.1) + // 4. write /build/uitest/env.properties (apps/multiplatform/build/uitest), including the run directory (-PuiRunDir) and the two lists +} + +tasks.register("desktopUiTest") { // KotlinJvmTest with targetName, so Kover instruments it (7.8) + targetName = "desktop" + onlyIf { System.getenv("SIMPLEX_UITEST_RUNNER") == "1" } // only from uitest.sh: Kover makes check/build/koverVerify depend on every instrumented desktop KotlinJvmTest (7.8) + val dt = tasks.named("desktopTest").get() + testClassesDirs = dt.testClassesDirs; classpath = dt.classpath + include("chat/simplex/app/ui/**") + forkEvery = (findProperty("uiForkEvery") as String?)?.toLong() ?: 0 // 0 = one JVM per fork runs many classes (15.4 item 24); -PuiForkEvery=1 = one JVM per class + maxParallelForks = (findProperty("uiForks") as String?)?.toInt() ?: 1 + maxHeapSize = "2g" + timeout.set(Duration.ofHours(12)) // last backstop (6); `import java.time.Duration` at the top of the file: inside the script `java` resolves to the project's `java` extension, so `java.time.…` doesn't compile (found in P1a) + filter { isFailOnNoMatchingTests = findProperty("uiAllowEmpty") == null } // the runner's passes may each match nothing (7.1.1) + useJUnit { + fun cats(p: String) = (findProperty(p) as String?)?.split(",")?.filter { it.isNotBlank() }?.map { "chat.simplex.app.ui.$it" }?.toTypedArray() + cats("uiTags")?.let { includeCategories(*it) } + cats("uiExcludeTags")?.let { excludeCategories(*it) } + } + environment.remove("XDG_DATA_HOME"); environment.remove("XDG_CONFIG_HOME") + environment("GSETTINGS_BACKEND", "memory") // the system theme then resolves to light, whatever the host's GTK theme (7.7) + environment("XDG_CACHE_HOME", "${findProperty("uiRunDir")}/cache") // fontconfig's cache for the pinned font directory and JNA's native-library extraction, instead of the real ~/.cache (measured) + environment("FONTCONFIG_FILE", file("src/desktopTest/uitest-fonts/fonts.conf").absolutePath) // pinned fonts (7.7), committed beside fonts.conf, + environment.remove("FREETYPE_PROPERTIES") // hinting overrides would change every screenshot (7.7) + jvmArgs("-Duser.language=en", "-Duser.country=US", "-Duser.timezone=UTC", "-Djava.io.tmpdir=${findProperty("uiRunDir")}/jvmtmp") // AppCommon.desktop.kt:19 captures defaultLocale at class init; UTC for screenshots (7.7) + systemProperty("simplex.uitest.shots", (findProperty("uiShots") as String?) ?: "update") // update | verify | off (7.7) + systemProperty("simplex.uitest.shotTolerance", (findProperty("uiShotTolerance") as String?) ?: "0") + // Kover agent on this task unless -PuiCoverage=off; forks append to one per-task report, copied per pass by the runner (7.8) + systemProperty("java.awt.headless", "true") + systemProperty("skiko.data.path", rootProject.file("build/uitest/skiko").absolutePath) + systemProperty("simplex.uitest.env", rootProject.file("build/uitest/env.properties").absolutePath) + systemProperty("simplex.uitest.pass", (findProperty("uiPass") as String?) ?: "1") // failure reports go to /report/pass (7.5) + systemProperty("simplex.uitest.strict", (findProperty("uiStrict") != null).toString()) // strict traceability (10.1, P7) + systemProperty("kotlinx.coroutines.io.parallelism", "256") // old receivers block IO threads up to 300 s each (7.3) + outputs.upToDateWhen { false } // the core, CLIs and servers are not task inputs; always run + val pass = (findProperty("uiPass") as String?) ?: "1" // each runner pass keeps its own results (7.1.1) + reports.junitXml.outputLocation.set(layout.buildDirectory.dir("test-results/desktopUiTest/pass$pass")) + reports.html.outputLocation.set(layout.buildDirectory.dir("reports/tests/desktopUiTest/pass$pass")) + binaryResultsDirectory.set(layout.buildDirectory.dir("test-results/desktopUiTest/pass$pass/binary")) +} +``` + +- `env.properties` holds the absolute paths of `libapp-lib.so` and its library folder, the current CLI, the stable CLI and the tag it was downloaded for, each fixture CLI and its tag, `uitest-servers`, the repo root, the run directory (port slots, 8.2; per-JVM directories, 7.1; failure reports, 7.5), and the current CLI's chat and agent migration lists (7.1). The catalogue is read from the test classpath (10). +- The JUnit category interfaces (`Smoke`, `StableCompat`, `NeedsRelay`, `NeedsNames`, `NeedsFixture`, `Multicast`, `Slow`) live in package `chat.simplex.app.ui`; Gradle loads every listed category up front. +- **Per-JVM data directories.** The app's paths are read once at class initialisation: `dataDir` from `XDG_DATA_HOME` or `user.home` (D/platform/Platform.desktop.kt:6-8), `desktopPlatform.configPath` (:7, :12; settings file at D/platform/Resources.desktop.kt:39-40), `tmpDir` from `java.io.tmpdir` (D/platform/Files.desktop.kt:15). The task removes `XDG_DATA_HOME` and `XDG_CONFIG_HOME`; the `UiTest` base class's companion initialiser — which runs before any app class is initialised (JUnit and Gradle load classes without initialising them) — registers the harness's single shutdown hook of 8.2 (Kover's agent has its own) (it stops peers and relays, closes `uitest-servers`' stdin and waits for it, then destroys the remaining descendant processes with `destroyForcibly()`, `ProcessHandle.current().descendants()` — which also covers the `gsettings monitor` process the app's theme detector starts, Theme.desktop.kt:8-11, which never exits on its own under the memory backend, measured one orphan per JVM; it first sets a shutting-down flag, after which `UiUncaught` drops errors, since destroying the `gsettings monitor` process makes the detector's reader thread throw an uncaught `NullPointerException`), creates a fresh directory per JVM, `/jvm--` (slots are reused by later JVMs, and a reused directory would bring back the previous JVM's peer databases, server stores and settings) (on the same filesystem as the checkout, not `/tmp`, which may be a small root filesystem; one test picks a sparse file over 1 GB), and sets `user.home` and `java.io.tmpdir` inside it. `bootJvm()` asserts that `dataDir` and `configPath` are inside that directory, failing fast otherwise. Setting `java.io.tmpdir` at runtime covers the app's `tmpDir`, but not the JDK's NIO `Files.createTempFile`/`createTempDirectory`, nor `File.createTempFile` once it has been used (measured, JDK 17), so the task also passes `-Djava.io.tmpdir=/jvmtmp` (created by the runner) and library and harness temp files stay off `/tmp`. +- Native libs: `libsimplex.so` and GHC libs are staged into `apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-x86_64` (gitignored; `bash ~/build/stage-libs.sh` in the worktree, or upstream `scripts/desktop/build-lib-linux.sh`). `libapp-lib.so` comes from `:desktop:cmakeBuildAndCopy` (it links against the staged `libsimplex.so`, cpp/desktop/CMakeLists.txt:57). The staged core and the current CLI must be the same version: fixture SQL edits run the CLI with `-y` on the GUI's database (11.1), so a newer CLI would apply migrations the GUI's core doesn't know, and "current" peers would differ from the GUI. The check compares the migrations applied to a fresh database. `uitestPrepare` runs the current CLI once on a scratch database with `--user-display-name uitest -e "/sql chat select name from migrations order by name" -t 0`, and again with `/sql agent …` (not `--maintenance`, which exits on a database with no user, Hs/Core.hs:89-91; the CLI's `/v` prints only the version, View.hs:216, :2711-2716), and writes both lists to `env.properties` (dropping the `Current user:` line, the `name` header row and the trailing blank line). The first `resetForTest` in the JVM whose init succeeds (a fixture with a startup database error defers it to the next) sends `CC.ShowVersion()` (SimpleXAPI.kt:4110) through `chatController.sendCmd(null, …, log = false)` — not `apiGetVersion()`, which returns only the version (SimpleXAPI.kt:2737-2742), and without logging, so the console of the JVM's first test shows no extra items — and reads `chatMigrations`/`agentMigrations` (`UpMigration.upName`, DatabaseUtils.kt:109-112) from the `CR.VersionInfo` result (:6901; the core reads the database's `migrations` table sorted by name, Hs/Library/Commands.hs:3671-3676, the same rows as the CLI's query), and fails the current test and poisons the JVM, with both lists in the report, when they differ. + +#### 7.1.1 Runner script + +(As built: see 0.5.9. Differs: one pass (`-PuiPass=1`), so no `Multicast` pass, empty-run check, screenshot cleanup or coverage merge, and `-PuiTags` has no effect; added: the 3000 MB `MemAvailable` guard, the `-O0` cabal build with `cabal list-bin`, the peer database template, the fingerprint skip of the outside Gradle step, automatic `-PuiForks`, in-process Kotlin compilation; `--stop` runs only after the outside step, when it runs.) + +Tests are run by `apps/multiplatform/scripts/uitest.sh [gradle args]`: + +1. **Outside the namespace** (network allowed; the script runs in `apps/multiplatform`, and relative paths below are from there): the script first runs `mkdir -p build/uitest` and takes `flock -n` on `build/uitest/runner.lock`, held through both passes (a second runner would rewrite `env.properties` and re-copy `libapp-lib.so` under the first run's JVMs, so it exits instead), then creates a fresh run directory `$PWD/build/uitest/run-` and its `jvmtmp` subdirectory (the test JVMs' launch-time `java.io.tmpdir`, 7.1) (absolute, since `uitestPrepare` and the test JVM resolve paths from `common/`; port-slot locks, 8.2) and removes older `run-*` directories except the last 5 (keeping 5 old ones plus the new one, since a run keeps each JVM's GUI data and server stores, the peer databases of failing tests, and its reports), then runs `./gradlew -g "$PWD/.gradle/uitest-home" --no-daemon :desktop:cmakeBuildAndCopy :common:desktopTestClasses :common:uitestPrepare` with `-PuiRunDir=` (written into `env.properties`) and the user's `-PuiStableTag` and `-PuiFixtureTags` (they select the binaries `uitestPrepare` downloads), then `./gradlew -g "$PWD/.gradle/uitest-home" --stop`. Both steps use their own Gradle user home, `.gradle/uitest-home` in `apps/multiplatform` (git-ignored, and outside `build/`, which the root `clean` task deletes, build.gradle.kts:112-113; filled by this outside step, which has network; the first run downloads the wrapper distribution and dependencies once, an estimated 1–2.5 GB on disk): a Gradle process holds cache locks (`caches/journal-1`, shared across Gradle versions) that a Gradle process on the other side of the namespace can't ask it to release, so with a shared home an outside daemon makes the inside run fail with "Timeout waiting to lock journal cache", and a run of several hours makes every outside build on the same home fail the same way (the daily prebuild timer, `~/build/linux.sh`, IDE syncs; verified both ways, the outside build failed after 69 s). A build or IDE sync of the same checkout still conflicts during a run, on the project's own `.gradle/8.12/fileHashes` lock (verified: failed after 64 s), so other work during a run uses another worktree. Every invocation on that home uses `--no-daemon`, so its only daemon is the single-use one that exits with its build; the script still runs `./gradlew -g … --stop` before entering the namespace and before pass 2, as a guard against a process left by an interrupted run. Before the Gradle invocation, the outside step runs `cabal build -f uitest --builddir=dist-uitest exe:simplex-chat exe:uitest-servers`, because `uitestPrepare` runs the built CLI (a separate build directory, because the desktop library is built with `client_library` and sharing `dist-newstyle` would rebuild the package each time). +2. **Inside the namespace** (8.3; the inner Gradle runs get `SIMPLEX_UITEST_RUNNER=1`, without which `desktopUiTest` is skipped, 7.1): the script re-executes itself under + ```sh + self=$(readlink -f "$0") # set at the top of the script, so the re-exec still resolves whatever directory step 1 runs in + unshare -rn --pid --fork --kill-child --mount-proc sh -c ' + ip link set lo up && ip link set lo multicast on && ip route add 224.0.0.0/4 dev lo src 127.0.0.1 && + exec "$0" --inside "$@"' "$self" "$@" & + trap 'kill -KILL $!' INT TERM; wait $! + ``` + The outer script doesn't `exec`: `unshare --fork` blocks SIGINT and SIGTERM while it waits, so the outer script kills it with SIGKILL on Ctrl+C or SIGTERM, and `--kill-child` tears the namespace down; it returns `wait`'s status (verified with util-linux 2.38.1: no leftover processes, status 130). Because `unshare` is started with `&` from a non-interactive shell, SIGINT is ignored for everything inside the namespace, and `dash` can't trap a signal ignored on entry; so Ctrl+C is handled only by the outer trap. In `--inside` mode the script is PID 1 of the pid namespace, which receives only signals it handles; it runs `trap 'kill -TERM -1 2>/dev/null; exit 143' TERM` (for a SIGTERM sent from inside), records each pass's exit code and exits non-zero if either failed. + and runs the Gradle passes there: `./gradlew -g "$PWD/.gradle/uitest-home" --offline --no-daemon :common:desktopUiTest …`. The whole Gradle process runs inside, because the test worker connects back to the Gradle process over loopback (a JVM alone in the namespace can't reach it; verified). `-g` is needed anyway, because inside the user namespace the uid is 0 and `user.home` resolves to `/root`. The pid namespace kills every leftover process (peers, relays, servers) when the run ends. +3. **Two passes.** The script reads the user's `-PuiTags`, `-PuiExcludeTags`, `--tests`, `-PuiForks`, `-PuiShots` and `-PuiCoverage` arguments (the last two decide the whole-run screenshot and coverage steps, 7.7, 7.8). Pass 1 runs with the user's arguments and `Multicast` added to the exclusions. Pass 2 runs only if the user's `uiTags` is empty or contains `Multicast`: it runs the user's arguments (exclusions, `--tests`, `-PuiShots`, `-PuiCoverage`, `-PuiStrict`, `-PuiShotTolerance`) with `-PuiTags=Multicast` and `-PuiForks=1` (8.3) replacing the user's `-PuiTags` and `-PuiForks` rather than appending to them. Pass 1 is skipped when the user's `uiTags` is exactly `Multicast`. Both passes add `-PuiRunDir=` (the task's `java.io.tmpdir` is its `jvmtmp`, 7.1), `-PuiAllowEmpty`, so a pass whose filters match nothing doesn't fail Gradle ("No tests found for given includes", verified), and `-PuiPass=1` or `-PuiPass=2`, so each pass writes its XML and HTML results to its own directory (a second Gradle invocation of the same test task deletes the first one's results, verified with Gradle 8.12). The script deletes both pass directories before pass 1, and fails the run if neither pass executed a test (it counts the XML results in `common/build/test-results/desktopUiTest/pass1` and `pass2`, the `:common` project's build directory). After both passes, when no test filter (`uiTags`, `uiExcludeTags`, `--tests`) was given and `uiShots` is `update`, it deletes committed screenshots whose test no longer exists or that a passing test no longer produced, and regenerates the screenshot docs index (7.7). After both passes it also merges the per-pass coverage reports and writes the coverage report and summary (7.8), unless `-PuiCoverage=off`. + +Usage: `scripts/uitest.sh` (full run), `scripts/uitest.sh -PuiTags=Smoke -PuiExcludeTags=StableCompat` (smoke run; P1b, no categories yet), `scripts/uitest.sh --tests '*OneTimeLink*Test'`, `scripts/uitest.sh -PuiForks=2` (default chosen from free memory: 10.2). + +### 7.2 Boot, reset and restart + +(As built: see 0.5.3 and 0.5.4. Differs: `bootJvm` has no collector change, uncaught handler, `platform` or seams, uses a no-op `NtfManager` and applies the theme; `resetForTest(user)` has no fixture, uses `deleteChatDatabaseFilesAndState()` (so `dataDir/tmp` is not deleted), and runs `PrefStores.restore` → `clearUiState` → `TestServers.start()` → `DbTemplate.installIfPresent` (so the template's key is installed after the prefs are restored, as 15.1 says; the code below has the opposite order); no `MigrationCheck`; `TestRoot` provides `LocalClipboardManager` and `LocalUriHandler` only, with no throwing option.) + +`initHaskell()` is private to the desktop module (desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt:50-91), so the harness repeats its essential steps. `acquireSingleInstance()` (D/SingleInstance.kt:33) is skipped. The reset adds no app code beyond `UiIdle` (6): it uses existing functions, public state and reflection from test code (private fields, and the model walk of 7.3). + +```kotlin +object UiTestApp { + val dbPrefix: String get() = dbAbsolutePrefixPath // C/platform/Files.kt:24, D/platform/Files.desktop.kt:20 + fun bootJvm() { // once per JVM, from the base class + TestScopeKt.setCatchNonTestRelatedExceptions(false) // by reflection, before the first runTest (6) + Thread.setDefaultUncaughtExceptionHandler(UiUncaught) // records uncaught app errors with the current phase (6) + check(dataDir.startsWith(UiEnv.jvmDir) && File(desktopPlatform.configPath).startsWith(UiEnv.jvmDir)) // 7.1 per-JVM data directories + System.load(UiEnv.libAppLib); initHS() // C/platform/Core.kt:18 + Locale.setDefault(Locale.ENGLISH) + uiTagsEnabled = true // test tags and typed semantics are emitted only in test/debug builds (3.1) + ntfManager = RecordingNtfManager // lateinit, C/platform/NtfManager.kt:18 + platform = UiTestPlatform // same PlatformInterface as desktop/Main.kt:65-90 + simplexWindowState.windowState = WindowState(size = DpSize(1600.dp, 900.dp)) // lateinit, D/DesktopApp.kt:271 + tmpDir.mkdirs() // databaseExportDir; desktop main creates it + runMigrations() // C/platform/AppCommon.kt:46; writes lastMigratedVersionCode + appPrefs.appearanceBarsBlurRadius.set(0) // toolbar blur off in every test, onboarding included, for performance (user decision 2026-10-13); + // set before the capture, so every reset restores it; tests about blur turn it on + PrefStores.capture() // settings.properties, themes.properties, themes.yaml as they are now (7.3) + ModelSnapshot.capture() // initial values of ChatModel, ChatsContext, AppLock fields (7.3) + UiSeams.installFakes() // recorder, audio/video provider, Ctrl+V clipboard reader (11.2); + // the display clock is fixed or real per test, from uiTest(realTimes) (7.7) + } + + fun resetForTest(user: String?, fixture: ((dbPrefix: String) -> Unit)?) { // called by app() at the start of each test + UiIdle.drain() // let the previous test's jobs finish (6) + stopAndCloseCore() // as deleteStorageAndRestart does (C/views/localauth/LocalAuthView.kt:55-68): + // wait while ctrlInitInProgress (at most 10 s, else poison the JVM with a report); + // stopChatAsync only if chatRunning == true (it also sets chatRunning = false); + // chatCloseStore only if ctrl != null && ctrl != -1; then setChatCtrl(null) + // (DatabaseView.kt:581), which also stops the receiver on the old controller + UiIdle.drain() // events in flight when the receiver stopped, before clearUiState (as restartCore, 7.3) + deleteDatabaseFiles() // the files deleteChatDatabaseFilesAndState removes (C/views/database/DatabaseView.kt:556-599), + // plus dataDir/tmp (coreTmpDir, the core's temp folder and the agent's XFTP work folder; deleteChatDatabaseFilesAndState removes java.io.tmpdir/simplex and dataDir/migration_temp_files, not dataDir/tmp, DatabaseView.kt:572-574; a transfer still running when the core stops leaves its prepared copy there, a failed one is removed by the agent, FileTransfer/Agent.hs:513-517), + // without its asynchronous UI clearing (:585-596); clearUiState does that synchronously; + // then re-creates filesDir, tmpDir and wallpapersDir as it does (:570, :574, :576), since they are otherwise created once per JVM + DbTemplate.installIfPresent() // the run's pre-migrated empty database and its key (/db-template), + // so initChatController opens instead of migrating (15.1: reset 7.2 s → 0.94 s); + // the JVM that finds none migrates once, closes the store, copies the files and key, + // publishes them with an atomic rename and re-opens; FIXTURE rows replace the files after this + PrefStores.restore() // 7.3 + clearUiState() // 7.3, synchronously on the EDT: model, navigation, window state + TestServers.restoreBaseline() // 8.2 + fixture?.invoke(dbPrefix) // FIXTURE rows: prepare the database while the core is stopped (11.1) + if (user != null) TestPrefs.preset() // 11.1 "Pre-set prefs"; onboarding boots keep defaults + runBlocking { initChatController() } // C/platform/Core.kt:70; fresh DB → random key, no prompt; a fixture DB made + // without a key opens with the empty key (DatabaseUtils.kt:51-62) + if (!migrationsChecked && chatModel.chatDbStatus.value == DBMigrationResult.OK) MigrationCheck.compare() // 7.1: CC.ShowVersion's chatMigrations/agentMigrations vs the CLI's lists; poison on mismatch + if (chatModel.chatDbStatus.value != DBMigrationResult.OK) return clearTransientUi() // fixture with a startup DB error + val fixtureUser = chatModel.currentUser.value // a fixture database may already have a user (initChatController started it) + if (fixtureUser == null && user == null) return clearTransientUi() // onboarding: Core.kt:153-166 sets Step1 and starts without a user + val u = fixtureUser ?: runBlocking { chatController.apiCreateActiveUser(null, Profile(user!!, "", null)) }!! // display name only + chatModel.localUserCreated.value = true + appPrefs.onboardingStage.set(OnboardingStage.OnboardingComplete) + chatModel.currentUser.value = u // getUserServers/setUserServers read the user id from the model (currentUserId) + if (fixtureUser == null) runBlocking { chatController.startChat(u) } // SimpleXAPI.kt:663 + runBlocking { TestServers.configure(u) } // 8.2: /_servers with the operator groups disabled and one custom group; + // assigns the fresh getServerOperators result to chatModel.conditions; + // after startChat: before it the core answers chatNotStarted (found in P1a) + setLastVersionDefault(chatModel) // WhatsNewView.kt:1024, so What's New doesn't open + clearTransientUi() // 7.3: modals, passcode views, alerts and toasts stale jobs put back; + // keeps chatDbStatus, localUserCreated and the chats startChat loaded + } + + fun restartCore(fixture: ((dbPrefix: String) -> Unit)?) { … } // RESTART rows, 7.3 +} +``` + +- Each test gets a new composition from the scene runner: `runInternalSkikoComposeUiTest(width = 1600, height = 900, coroutineDispatcher = StandardTestDispatcher()) { setContent { TestRoot { AppScreen() } } }` (14; the fallback is `runDesktopComposeUiTest(width = 1600, height = 900)`; density 1, so 1600 px = 1600 dp). The size matches `windowState` and leaves room for the end pane: END modals expand the window to `388·√fontScale·2 + 590` dp (ModalView.kt:146-148, Resources.desktop.kt:106-108), 1366 dp at font scale 1 and about 1540 dp at the largest desktop font scale, 1.5 (Appearance.kt:251). +- `TestRoot` provides the test stubs as composition locals: in-memory `LocalClipboardManager` **and** `LocalClipboard` (app code uses the former, ~40 sites; Compose text fields and `SelectionContainer` use the latter in 1.8.2), a recording `LocalUriHandler` (links, the browser launch for calls, D/views/call/CallView.desktop.kt:157-174). The stubs belong to the test (so a migration test can carry a link across a `restart()`). +- Window-only features (tray, window-state persistence, the file-dialog windows, toasts' `LaunchedEffect`, floating terminal, close policy; D/DesktopApp.kt:40-257) are not rendered; 7.6 says how each is covered. + +### 7.3 Isolation between tests, and restart inside a test + +(As built: see 0.5.3, 0.5.4. Differs: `ModelSnapshot` unwraps and walks `Lazy` fields (15.4 item 26); its roots are `ChatModel`, `AppLock`, `ConnectProgressManager`, `ChannelRelaysModel`, `BadgeModel`, `VideoPlayerHolder`; no `changingActiveUserMutex` check, no manual `migrationState`, `ProcessedErrors` timers not cancelled explicitly; `restartCore` drains once and has no fixture, lock-screen path or alert exit (desktop polls 30 s for init; Android restarts the core before relaunching the activity, 15.4 item 10).) + +Every test calls `app(...)` first, which runs `resetForTest`: a fresh database, restored preference stores, cleared in-memory state, servers back to their baseline (8.2), then a fresh composition. Peers are started per test, each in a directory of its own test (8.1), and stopped at the end. `hs_init` runs once per JVM. + +**`PrefStores.capture()` / `restore()`.** Each `SharedPreference` copies its value into a private `_state` when created and stores no default (SimpleXAPI.kt:3903-3917), and `appPrefs` is a lazy singleton (:537), so rewriting the files is not enough. `capture()` (in `bootJvm`, after `runMigrations`) copies the raw `Properties` of `settings.properties` and `themes.properties` and the content of `themes.yaml`, as `runMigrations()` leaves them (it writes `lastMigratedVersionCode`, `currentThemeIds` and an empty theme-overrides list, AppCommon.kt:46-66, Files.kt:153-167). The restore: clear and refill the raw `Properties`, rewrite `settings.properties`, `themes.properties` and `themes.yaml` from the capture (otherwise the files would hold the previous test's values until the first pref write); null the private static `themeOverridesStore` cache (SimpleXAPI.kt:528, used by the preference at :388-391); refresh the cached state of every `SharedPreference` field of `AppPreferences` by reflection (`_state.value = get()`), never calling `set`: `set` would write every default into the file, and defaults computed from other prefs while the key is absent would stop following them (`mkTimeoutPreference`, SimpleXAPI.kt:320-327, picks the proxy defaults only while the timeout key is unset); apply the restored theme through `ThemeManager.applyTheme(appPrefs.currentTheme.get()!!)` (as Theme.kt:823 does; it writes `currentTheme` only when the value differs, ThemeManager.kt:105-108, so passing the stored value writes nothing); `Locale.setDefault(Locale.ENGLISH)` (switching language calls `Locale.setDefault`, D/views/usersettings/Appearance.desktop.kt:52-60); set `.value` of the `ChatModel` fields that are copied from prefs once (`val … by lazy { mutableStateOf(…) }`, ChatModel.kt:211/220/222/248) (`notificationPreviewMode`, `showAuthScreen`, `showChatPreviews`, `simplexLinkMode`). + +**`clearUiState()`** (before the core starts, in `resetForTest` and `restartCore`), on the EDT, waiting for completion: +- everything `clearTransientUi()` clears (below); +- `ChatModel`, its `ChatsContext` objects and `AppLock`, by reflection rather than by a hand-written list (reviews kept finding fields the list missed, e.g. `desktopOnboardingRandomPassword`, which would make later onboarding tests skip the passphrase step, WelcomeView.kt:400; `showCallView`, App.kt:226-237; `AppLock.laFailed`, App.kt:218): `ModelSnapshot.capture()` in `bootJvm`, before the first init, records the initial state of these objects by walking their declared fields (private ones included), and `clearUiState` restores it: `MutableState` and `MutableStateFlow` fields get their initial value; `SnapshotStateList`/`SnapshotStateMap` and plain mutable collections (tested with Kotlin `is MutableCollection<*>` / `is MutableMap<*, *>`, which excludes Kotlin's read-only collections such as the `EmptySet` in `terminalsVisible`, ChatModel.kt:183, so those are restored by reference only) are cleared and refilled with their initial contents; non-final fields get their initial reference back and then, like every field, the captured value inside it (a `var` holding a `MutableState`, such as `draft`/`draftChatId`, ChatModel.kt:240/242, is never reassigned but its `.value` changes), which covers plain `var`s the app sets outside any state (`chatListBanner`, ChatModel.kt:206; `centerPanelBackgroundClickHandler`, :274, set at UserAddressView.kt:549 in a `LaunchedEffect` with no `onDispose`); final fields holding an object of an app class are walked the same way, once per object, which reaches each context's `chatState` (`ActiveChatState`: six `MutableStateFlow`s and `@Volatile var selectionActive`, ChatItemsMerger.kt:196-206, whose six flows production resets through `clearAndNotify`, DatabaseView.kt:587; `selectionActive` is not reset there), `popChatCollector` (its pending map; stale chat ids would pop the wrong chats; `deleteChatDatabaseFilesAndState` also clears it, DatabaseView.kt:589/594) and `processedCriticalError`/`processedInternalError` (ChatModel.kt:270-271; their `timer` job is cancelled first, as a pending one waits up to 60 s and then calls `hideAllAlerts`, ProcessedErrors.kt:15-28; it is launched with `withLongRunningApi(idle = false)` so that `UiIdle.drain()` doesn't wait for it before the reset cancels it). The walk skips Kotlin `object`s (`controller`, managed by `resetForTest`), `MutableSharedFlow`s that aren't `MutableStateFlow`s (the check for `MutableStateFlow` comes first, as it is a subtype) and `lazy` delegates. Exceptions, handled by hand: `changingActiveUserMutex` (ChatModel.kt:257, a kotlinx `Mutex`, not an app class; a job abandoned by `drain` may hold it, and every later user switch would then hang) is checked instead: it is a `static final` field of the `ChatModel` object (ChatModel.kt:140), so reflection can't replace it, and its only holders are `currentUserId` and the active-user section (SimpleXAPI.kt:653, :749-753), so a lock still held after `drain()` means a job is stuck in the core and the JVM is poisoned with a report (6); `migrationState` (re-created as MigrateToDevice.kt:55 does), the four fields copied from prefs (restored by `PrefStores`), and `chatsContext`/`secondaryChatsContext` themselves (the existing objects are reset in place, `secondaryChatsContext` set to null). The fields that matter most for the next test — `chatId`, chats, items, `chatState` (each flow and `selectionActive`), `chatListBanner`, `users`, `draft`/`draftChatId` (ComposeView.kt:1447-1462 can save them on dispose; ids restart at 1 in a fresh database), `chatDbStatus`, `chatDbEncrypted`, `localUserCreated`, `chatRunning`, remote state, calls — are asserted in P1's harness tests to be back to their initial values after a reset; +- other singletons: `BadgeModel`, `BadgeStore`'s load state, `ChannelRelaysModel`, `ConnectProgressManager`, `VideoPlayerHolder`, `OnboardingSharedState.selectedOperatorIds` (YourNetwork.kt:37-39), `CIFile.cachedRemoteFileRequests` (ChatModel.kt:4626, read by `fileReady` for every file, Files.kt:170-172, and cleared in production only on a chat switch, ComposeView.kt:1425, whose `KeyChangeEffect` skips the first composition), image caches: `clearImageCaches()` (D/views/helpers/Utils.desktop.kt:132-134) empties `loadedImageCache` (:128, a 30-entry cache keyed by the file's path, which the database deletion of 7.2 doesn't clear, so a later test's file with the same name would show the previous test's bitmap), and `WallpaperType.cachedImages` (ChatWallpaper.kt:342); the seam fakes' per-test state, `AudioPlayer.currentlyPlaying` and `RecorderInterface.stopRecording` (11.2); +- file-level mutable state of UI files, reset by reflection on their `…Kt` classes: `ChatListViewKt.lazyListState` (ChatListView.kt:907, the chat list's scroll position saved in `onDispose`, :979, and used as the next composition's start, :240) back to `0 to 0`, and `ChatViewKt.reportsListState` (ChatView.kt:1747) to null; otherwise each test would start with the list where the previous one left it, rows above it uncomposed and unfindable, and `restartCore` would restore an offset a real restart doesn't. P1's harness tests assert the list is at index 0 after a reset, and the source-level check of 9.4 fails on top-level `var`s and mutable `val`s of UI files that are neither reset nor allow-listed (the allow-list starts with those found harmless: `chatViewScrollState`, cleared when its scroll collector completes, ChatView.kt:2481-2486, measured; the call WebSocket list, CallView.desktop.kt:28; the composable bookkeeping sets, Utils.kt:632-633; the lazily built icons, CustomIcons.kt:37/83/111/143; the update checker's job, AppUpdater.kt:160, whose job is `idle = false`; extension properties without a backing field, such as MigrateFromDevice.kt:102 and MigrateToDevice.kt:117, hold no state and are skipped); +- window state: `simplexWindowState.windowState` re-created at 1600 × 900 dp (END modals widen it, ModalView.kt:146-148), `windowFocused` and `windowVisible` true (`NTF` tests set focus to false; left false it stops the 1 s status poll, ChatListView.kt:688), `AppLock.clearAuthState()`. + +**`clearTransientUi()`** (also the last step of `resetForTest` on every path, including the early returns for a startup DB error and for onboarding), on the EDT: `ModalManager` all placements (`closeAllModalsEverywhere`, ModalView.kt:260-266) and the private `passcodeView`/`onTimePasscodeView` (:112-113, via reflection); pending `DialogState`s completed with an empty result (as at the end of each test, 6); both alert managers (`hideAllAlerts`, AlertManager.kt:48; alerts a test left open were already dismissed at its end, 6); recorded toasts. + +**`restartCore(fixture)`** (RESTART rows) simulates a real app restart: end the composition first (its `onDispose` handlers start core jobs, e.g. ConnectMobileView.kt:339-342 stops the remote host, SetupDatabasePassphrase.kt:82-87 starts the chat), wait for idle, then `UiIdle.drain()`; if the controller is neither null nor -1 (a startup DB error leaves it null; -1 is the process-start value, SimpleXAPI.kt:535, which the lock-screen path keeps because init is skipped, AppCommon.desktop.kt:35; `sendCmd` would pass -1 to the core as a pointer) and `chatRunning` isn't false, raw `apiStopChat` (not `stopChatAsync`, which would set `chatStopped` and trigger the "Start chat?" alert on start, Core.kt:61-62; `apiStopChat` leaves `chatStopped` unchanged, SimpleXAPI.kt:1093-1097), then `chatModel.chatRunning.value = null` (the process-start value, ChatModel.kt:146; with `true` left in place, `startChatWithoutUser` returns at once, SimpleXAPI.kt:705, and a user-less app stays on the splash screen); `chatCloseStore` under the same controller check, then `setChatCtrl(null)` and a second `drain()` (events in flight when the receiver stopped); `fixture?.invoke(dbPrefix)` (FIXTURE rows that copy, restore or edit data the test produced); clear in-memory state as `clearUiState` does, including `chatDbStatus` (files and preference stores are kept); then start as desktop startup does — `initChatControllerOnStart()` (Core.kt:59-67), or, when a self-destruct passcode is set, the lock-screen path (`AppCommon.desktop.kt:35` skips init; the harness restores the process-start controller with `setChatCtrl(null)` then `setChatCtrl(-1L)`, which LocalAuthView.kt:36 requires before it initialises) — and compose again with `setContent { TestRoot { AppScreen() } }` at once (the same `TestRoot` stubs), as desktop `showApp` composes while initialisation runs (initialisation can wait on an alert inside `AppScreen`, e.g. "Start chat?": Core.kt:61-62 passes `showStartChatAfterRestartAlert`, :226-235, and :170 awaits it; and `initChatControllerOnStart` only launches a coroutine, Core.kt:59, so `ctrlInitInProgress` may still be false right after it returns). Then poll until `chatModel.chatDbStatus.value != null && !chatModel.ctrlInitInProgress.value`, or until an alert is shown (`AlertManager.shared.hasAlertsShown()`); on the lock-screen path, where `chatDbStatus` stays null until the passcode is entered (LocalAuthView.kt:36-37), poll instead until `AppLock.userAuthorized.value == false` and the passcode view is shown (`ModalManager.fullscreen.showPasscodeInView`, App.kt:222, in the block at :210-224). Persistence rows additionally assert the stored value in `settings.properties`. + +A class may therefore hold many tests, and the catalogue's class mapping (10.1) works with any `forkEvery`. Each reset leaves the previous Haskell controller and its agent threads behind (`chatCloseStore` only closes the stores, Mobile.hs:364-367), and old receiver threads may block up to 300 s in `chatRecvMsgWait` (SimpleXAPI.kt:532, :962); both are bounded by the number of tests one JVM runs. With `forkEvery = 0` (as built, 15.4 item 24) that is a share of the whole run, so RSS (about 4.6 MB per reset, 15.1) and one receiver thread per reset accumulate per fork; `-PuiForkEvery=1` bounds them by one class (at most 29 tests, legend class map). + +### 7.4 Driver + +(As built: see 0.5.3, 0.5.4. Differs: no `UiDriver`/`UiNode`/`InProcessDriver`; the API is methods on each platform's `UiScope`; lookups use keys, `described(text)` or literal English text, not `MR.strings`; `exists` means any match; clicks are touch input at `tapPointInNode`; alerts are not closed with `press(Key.Escape)`.) + +```kotlin +interface UiDriver { + fun node(key: UiKey): UiNode + fun node(text: StringResource, within: SemanticsMatcher? = null): UiNode // MR.strings, resolved with generalGetString + fun node(text: String, within: SemanticsMatcher? = null): UiNode // literal text (message bodies, names) + fun nodes(matcher: SemanticsMatcher): List + fun scrollTo(list: UiKey, itemKey: Any) // performScrollToKey on the keyed lazy list + fun poll(what: String, timeout: Duration = 10.seconds, cond: () -> Boolean) // the loop of section 6 + fun wait(d: Duration) // the poll loop running for d of real time with no condition; only where a catalogue row names the interval (e.g. "retry more than 3 s after the failure") + fun escape() // simplexWindowState.backstack.lastOrNull()?.invoke(), as DesktopApp.kt:125-130 + fun composeField(): Pair // the message field's text and placeholder: desktop from semantics (EditableText; the placeholder Text exists only while the field is empty), Android from the EditText (catalogue 05 setup item 5) + fun dump(): String; fun screenshot(): ImageBitmap +} +interface UiNode { + fun click(): UiNode; fun rightClick(): UiNode; fun longClick(): UiNode; fun hover(): UiNode + fun hold(duration: Duration): UiNode; fun dragTo(other: UiNode): UiNode; fun clickAtTextRange(range: IntRange): UiNode + fun type(text: String): UiNode; fun press(key: Key, vararg modifiers: Key): UiNode + fun get(key: SemanticsPropertyKey): T?; fun assert(m: SemanticsMatcher): UiNode + fun child(text: String): UiNode? + fun capture(): ImageBitmap // captureToImage of this node (7.7) +} +class InProcessDriver(private val t: SkikoComposeUiTest) : UiDriver // the supertype of DesktopComposeUiTest; also fits runInternalSkikoComposeUiTest (14) +``` + +- UI labels are looked up by `MR.strings` key, so tests are locale-independent. Message text is looked up with `within = hasTagPrefix("chat.item[")` (a node or ancestor whose tag starts with `chat.item[`), because the chat list preview (`ChatPreviewView`) shows the same text. +- `node(...)` polls until exactly one node matches (a pending row and the contact row can briefly coexist); zero or several matches after the deadline fail with the semantics dump. +- Lazy lists: `scrollTo` uses the list key and the list's own item keys: `Pair(chat.remoteHostId, chat.id)` for the chat list (ChatListView.kt:1099) and `ChatViewItemKey(item.id, createdAt ms)` for chat items (ChatView.kt:2398 via `keyForItem`, :3663, which becomes `internal`). An item hidden inside a merged group is reached through the group's newest item. Lists without keys get keys (3.4). +- Input runs on the EDT: injected pointer and key events are dispatched on the thread that calls `performMouseInput`/`performKeyInput`/`performClick` (their `flush` calls `sendPointerEvent`/`sendKeyEvent` directly, InputDispatcher.skiko.kt:69-75, 130-138, 215-225, and the pre-event layout pass runs there too, BaseComposeScene.skiko.kt:60-65), which would run every click handler and the app code it calls off the EDT, racing the app's own EDT jobs. So the driver wraps each input injection in `runOnUiThread { … }`, which runs inline on the EDT (DesktopSynchronization.desktop.kt:28-40), keeping clock advances and lookups working; `hold` keeps its real-time wait between separate press and release injections. Semantics actions already run on the EDT (Actions.kt:581). The wrapper is per-platform: on Android the dispatcher already injects on the main thread and a wrap would throw (research doc §5.1). +- Dialogs and menus render as extra roots: lookups use `onAllNodes(..., useUnmergedTree)` and `isDialog()`. +- `escape()` covers modals; it invokes the back-stack lambda inside `runOnUiThread`, as the window key handler runs it on the EDT (D/DesktopApp.kt:125-128), since `closeModal` changes plain collections (ModalView.kt:105, 108, 168-179). Alerts register no back handler, so a test closes an alert with `press(Key.Escape)` on the dialog root (the Compose dialog dismisses on Escape). +- `type(text)` uses `performTextInput`. On the compose field any typed text of two or more characters whose every line names an existing regular file (absolute, or relative to the JVM's working directory) is attached as files instead (PlatformTextField.desktop.kt:97-104, :222-236); tests type such text only to attach a file: as the feature itself (02 `paste_filePathText_attachesFile`) or as the attach step of file tests whose rows say so (01 `serversSummaryFilesTabShowsXftp`, 02 §G). + +### 7.5 Failure output + +(As built: only `/failures/-NN-.png` (Android: pulled into `/failures/`), and a message with the in-flight list and the first 60 visible texts; peer timeouts add the peer's next 10 lines. P1b for the rest.) + +On any failed assertion or timeout: the semantics tree of every root (`onAllNodes(isRoot(), useUnmergedTree = true).printToString(maxDepth = Int.MAX_VALUE)`; the collection overload defaults to depth 0, Output.kt:83-86), a PNG via `captureToImage()`, the last 50 terminal lines of each peer and the last 50 log lines of each relay, the recorded toasts and URIs, the `UiIdle` in-flight list, recorded uncaught errors with their phase (6), the screenshot diff image in `verify` mode (7.7), and the app's command log, written to `/report/pass///` (the run directory comes from `env.properties` and the pass from the `simplex.uitest.pass` property, 7.1, so reports of earlier runs never mix with new ones; the test JVM's working directory is `common/`). + +### 7.6 Surfaces outside the scene + +(As built: in-memory `LocalClipboardManager` and recording `LocalUriHandler` (desktop); pending `DialogState`s are completed empty at reset only; notifications go to a no-op manager; no toast recording. Android uses Espresso-Intents, 0.5.4.) + +| Surface | Location | In tests | +|---|---|---| +| File choosers | D/views/helpers/DefaultDialog.desktop.kt:78-165; `simplexWindowState.openDialog/saveDialog/openMultipleDialog` (D/platform/Files.desktop.kt:68-101) | `UiFiles.respondOpen(path)`, `respondOpenMultiple(paths)`, `respondSave(path)` poll until the dialog's deferred is set and not completed (the `DialogState` field `onResult$delegate`, a `MutableState`, read by reflection; `isAwaiting` is not used, because it stays true until the waiting coroutine resumes, D/DesktopApp.kt:289-303, and a flow such as the QR save, QRCode.kt:90-97 → `shareFile` → a second `saveDialog.awaitResult`, Files.desktop.kt:78, opens the next dialog on the same `DialogState` within one poll step: polling for `isAwaiting` false failed 40 of 40 runs, measured), then complete that captured deferred, failing if `complete()` returns false (40 of 40 passed under load, measured); and `UiFiles.awaiting()` tells whether any `DialogState` has a non-completed deferred (for tests asserting that none opens; a waiter cancelled while it awaits, e.g. because its screen left composition, leaves its deferred set and not completed, since `awaitResult` clears it only after a normal resume, D/DesktopApp.kt:294-300, so `awaiting()` counts it until the end-of-test drain (6) or `clearTransientUi` completes it, and `respondX` only accepts a deferred that was not already pending at the test's previous UI action (the driver records the pending deferreds before each input action's input is injected, 7.4, and before each `respondX`, never after: flows that wait on a background dispatcher, such as the `CIFileView` save, `exportArchive` and `shareFile`, have already set their deferred when the click returns, and a record taken then made `respondSave` reject the right dialog in 12 of 24 runs, measured), so a stale one is never answered in place of the next dialog; a dialog must therefore be answered before the next input action, and when `respondX` times out while a recorded deferred is still pending it fails with "dialog opened before the previous action; answer it right after the action that opens it"); some save dialogs are directories-only (e.g. `saveTempImageUncompressed`, which waits on `saveDialog` with no file name, Utils.desktop.kt:239-240, DefaultDialog.desktop.kt:101-102), so `respondSave` takes a directory there | +| Text clipboard | `ClipboardManager.shareText` (D/platform/Share.desktop.kt:20-23); Compose `LocalClipboardManager`, `LocalClipboard` | in-memory implementations from `TestRoot` (headless AWT has no clipboard and the Compose one silently drops calls) | +| Ctrl+V in the compose field | D/platform/PlatformTextField.desktop.kt:141-152: if the (Compose) clipboard text is a list of existing files they are attached (:144-146, covered by the in-memory clipboard); otherwise — plain text or an image — it reads the AWT system clipboard (:150), which throws `HeadlessException` headless | file-list pastes need only `CB`; any other Ctrl+V in the compose field needs the approved clipboard seam (11.2) | +| Links, browser, calls | `LocalUriHandler`; outgoing calls open the browser through it (D/views/call/CallView.desktop.kt:157-174) | recording `LocalUriHandler` from `TestRoot`, with a harness option that makes `openUri` throw an `Exception` (an `Error` would escape the app's catch; the "Error opening browser" alert that ends the call, CallView.desktop.kt:168-175). An outgoing call invitation is sent only after the browser page reports its capabilities (CallView.desktop.kt:41-43), so outgoing-call tests assert the recorded call link and the waiting state; incoming calls from a peer (`/_call invite`), call items and alerts are tested (the incoming-call alert plays its ringtone through `SoundPlayer` → `AudioPlayer`, IncomingCallAlertView.kt:30-33, RecAndPlay.desktop.kt:275-289, so those rows add `AV`); WebRTC is not | +| Toasts | `simplexWindowState.toasts`, drained by the Window's `LaunchedEffect` (D/DesktopApp.kt:165-182), which isn't rendered | the harness drains and records them; tests assert on the record | +| Notifications | D/model/NtfManager.desktop.kt:97-148 (`displayNotification` → `displayNotificationViaLib`, `Toast.builder()` :123) | `RecordingNtfManager` records each call; the notification-preview text is built inside the desktop manager (:100-102), so preview-mode tests assert the preference and the recorded call | +| Esc | Window key handler (D/DesktopApp.kt:125-130) | `escape()` (7.4) for modals, `press(Key.Escape)` for alerts | +| VLC audio/video, voice recording | D/platform/RecAndPlay.desktop.kt:25-90/92; D/platform/VideoPlayer.desktop.kt:217 | approved seams (11.2) | +| OS folder opener | `desktopOpenDir`, D/platform/Files.desktop.kt:37-48 (no-op when `Desktop.isDesktopSupported()` is false), reached from the database-folder buttons via `desktopOpenDatabaseDir` (:33; DatabaseView.kt:243, DatabaseErrorView.kt:330) and from the app-update download (AppUpdater.kt:361-480, out of reach, 11.3); `openFile` (Share.desktop.kt:46) has no callers | not covered: rows are `IGNORE` until the seam of section 13 is approved | +| Tray, floating console window, window size/position persistence, close policy, crash-restart loop | D/DesktopTray.kt:34-80; DesktopApp.kt:34-120, 194-257, 215 | not rendered; catalogue 04 has `IGNORE` rows for them (11.3) | +| Single-instance dialog | D/SingleInstance.kt:104 | not rendered; not a user feature, no catalogue row | + +### 7.7 Screenshots (committed, rewritten only when they change) + +(As built: not built; P1b.) + +Tests save PNG screenshots that are committed to the repo. They serve three purposes: UI regressions and intended changes show up as image diffs in every PR at no extra cost; new and changed images prove that a real test rendered that screen (for unchanged screens the evidence is the run's `screenshots/summary.txt`, which lists every shot as unchanged, changed, new, unsettled, unquiet, orphaned or unverifiable); and together they document every screen and feature, indexed by the catalogue. Decided with the user: every passing catalogue test except the `REALTIME` ones (and none with `-PuiShots=off`) saves an end-of-test shot plus any named shots its author adds, and the PNGs are stored with Git LFS. A test's own assertions still read only semantics (1); screenshots are compared outside the test logic and fail a test only in `verify` mode (below). + +**What is captured.** After a passing body, once pending file dialogs are completed and before the rest of the end-of-test cleanup (6), the test captures `.png` (written once the test has passed). A test adds named shots at intermediate steps with `screenshot("after-join")`, or `screenshot("profile", of = node)` for one element (`UiNode.capture()`, 7.4). Named shots are captured when called but written (or compared) only after the test has passed, including the end-of-test checks of 6 (recorded uncaught and composition errors), so a failing test saves none. Named shots are the test author's choice and are not listed in the catalogue; they are taken with no file dialog pending (a dialog awaited from a tracked job keeps it in flight, so the quiet wait could only end `unquiet`, 6). Capture uses `SkikoComposeUiTest.captureToImage()` (the whole surface, including open dialogs, which are separate roots) or the node's `captureToImage()`, so the image is what the semantics assertions saw, after composition-scoped delays up to the settle time have run (for example visible items get marked read after 600 ms, ChatView.kt:3090-3094, and "in progress" spinners appear after 1 s, ChatListNavLinkView.kt:52-59). Failure screenshots stay separate (7.5) and are never committed. The key lint and the harness's own tests save no screenshots. + +**Where.** `apps/multiplatform/common/src/desktopTest/uitest-screenshots///[-].png`, next to a sidecar `….json` with the masks below and stable metadata (catalogue file, row feature, test name, step). The PNG also carries these fields as `tEXt` chunks. Nothing run-specific (dates, commit, run id) goes into the image or the sidecar, so an unchanged screen produces an unchanged file. A `…CompatStableTest` run saves its own copies under its class name. + +**Determinism.** A screenshot is only useful if an unchanged screen renders the same pixels on every run: +- Rendering: the fixed 1600 × 900 dp scene at density 1 (7.2), headless Skiko software rasterisation, the app's bundled fonts (Inter in `apps/multiplatform/common/src/commonMain/moko-resources/fonts`; Noto Color Emoji in `…/desktopMain/moko-resources/fonts`, applied only to emoji-only messages, an emoji-only compose field (ComposeView.kt:1071-1072), reactions and tag emoji, EmojiItemView.kt:20-21, ChatItemView.desktop.kt:29, TagListView.desktop.kt:60), with the host fonts pinned for the rest — monospace text (`FontFamily.Monospace`: markdown snippets, the console, verification codes, server addresses; ChatModel.kt:5178/5186, TerminalView.kt:174, VerifyCodeView.kt:82) uses the pinned DejaVu Sans Mono, and emoji inside ordinary text, which is Inter (C/ui/theme/Type.kt), use Inter's own glyph where it has one (e.g. ❤ ♥ ☀ ⚠ ⬆ ⬜ ▶ ↩ ©) and otherwise fall back to the pinned fonts (in `FontFamily.Default` text, which is DejaVu Sans 2.37, the emoji it covers are its own monochrome glyphs — most of the emoticons block, 64 of 80, but not e.g. 🙂 U+1F642 or 🙏 U+1F64F; ❤; and many Miscellaneous Symbols and Dingbats such as ☀ ☕ ⚡ ✌ ✔ — and all other emoji fall back to Noto Color Emoji; all deterministic) — through `FONTCONFIG_FILE` (7.1) pointing at a fontconfig file that the harness ships with a fixed font directory (DejaVu Sans, DejaVu Sans Mono and Noto Color Emoji; Compose's default family on Linux is the first available of Noto Sans, DejaVu Sans and Arial, which in the pinned directory is DejaVu Sans, and the app has text styles without a family, e.g. unread badges, ChatPreviewView.kt:596-600, and quotes, FramedItemView.kt:67/93 — without a sans font, `FontFamily.Default` throws "Could not load font"), so a screenshot depends only on the checkout, not on the host's fonts (glyphs in none of these fonts render as empty boxes, the same on every run; Skiko bundles its own FreeType inside `libskiko-linux-x64.so` and binds its `FT_*` calls to that copy, so the host's `libfreetype` does not affect rasterisation, but the bundled FreeType reads `FREETYPE_PROPERTIES`, which changes hinting, so the task removes it), the light theme and default prefs restored by `PrefStores` (7.3) — the default theme is SYSTEM (SimpleXAPI.kt:233), which jSystemThemeDetector resolves through `gsettings get org.gnome.desktop.interface gtk-theme` on any Linux (D/ui/theme/Theme.desktop.kt:8-24; its GNOME check is always true), so the task sets `GSETTINGS_BACKEND=memory` (7.1) and the host user's dark GTK theme can't turn every screenshot dark — the locale `Locale.ENGLISH` set by `bootJvm` and `PrefStores` (7.2, 7.3) and `-Duser.timezone=UTC` (7.1; the app formats times in the system zone, `TimeZone.currentSystemDefault()`, ChatModel.kt:3945-3994). +- Animation state: with the manual clock, `waitForIdle` does not finish animations (`isIdle` ignores them, ComposeUiTest.skiko.kt:254-266, and virtual time follows real time, 6; e.g. `FloatingDate` fades over 350 ms, ChatView.kt:2966-2970), so before each capture the driver first waits (at most 10 s) until `UiIdle` has nothing in flight and no core event has been processed for 1 s — content driven by core events keeps changing after the last assertion (a sent item's status, member joins, relays, transfer progress, chat-list order), and `UiIdle` counts processed receiver events for this — and then repeats: advance the clock by 1 s (`mainClock.advanceTimeBy(1000, ignoreFrameDuration = true)`; virtual time leads real time here, and the loop of 6 is shifted by the same amount afterwards), wait for idle, capture — until two consecutive captures are identical outside the masks with no core event processed between them, nothing in `UiIdle` in flight at the second one and nothing started or finished between them (settle steps run virtual time ahead, so a composition `delay` can start tracked work mid-shot) (at most 10 steps; a shot that never settles, or whose quiet wait hit its bound, is saved and marked `unsettled` or `unquiet` in the summary). A test whose last step leaves a known change pending (e.g. a sent message's delivery) waits for it before ending. Advancing is what renders a new frame: with `autoAdvance = false`, `captureToImage` only snapshots the last frame unless a layout is pending, so two captures without an advance would always be equal. Looped finite animations never settle — the live-message send-button pulse (SendMsgView.kt:533-544) and the typing indicator (TextItemView.kt:176-183) — so their composables are volatile too, and so are animated images (`rememberAnimatedImage`, D/views/chat/item/CIImageView.desktop.kt:25-26 and ImageFullScreenView.desktop.kt:25; the flag goes on a wrapper around `ImageView` in each platform's `SimpleAndAnimatedImageView` and on `FullScreenImageView`'s `modifier`, only when the image is animated (`looksAnimatable(data)`, AnimatedImage.desktop.kt:106, on desktop; on Android coil animates GIFs always, even under blur and modals, A/views/chat/item/CIImageView.android.kt:22-58, ImageFullScreenView.android.kt:32, and the check is a header check of the image bytes for GIF or animated WebP, since Android's `isAnimImage` needs a URI, Images.android.kt:127), so static images stay unmasked): their frames are decoded on `Dispatchers.Default` and wait in real time, so the test clock can't stop them (D/platform/AnimatedImage.desktop.kt:51-56, 131-148), and a GIF with loop count 0 plays forever. Indeterminate progress indicators (semantics `ProgressBarRangeInfo.Indeterminate`) are masked like `VolatileKey` nodes. A blinking caret blinks in step with the 1 s settle advances, so a focused field would look settled while differing between runs. The end-of-test shot therefore first advances the clock by one settle step (so delayed `requestFocus` calls in `LaunchedEffect`s fire — 17 call sites in 13 files request focus 100 ms–1 s after composition, e.g. WelcomeView.kt:175, AddGroupView.kt:181, and the confirm button of every two-button alert, AlertManager.kt:152), then clears focus, then settles; `screenshot(name)` follows the same sequence — one settle step, then it notes the focused text field (semantics `Focused`), clears focus, settles (at most 10 steps), captures — and then gives the field focus back with its `SemanticsActions.RequestFocus` action, so the next `press` still reaches it (3.5). The compose field keeps focus after sending (it clears focus only when sending becomes disabled, PlatformTextField.desktop.kt:71-73), and several screens focus a field soon after opening (above), so a named shot often has a focused field to handle. +- Times are fixed by a display clock; other volatile content is masked. Times are not only text: message bubbles reserve width for the time (`reserveSpaceForMeta`, CIMetaView.kt:130-195, used at TextItemView.kt:158, FramedItemView.kt:359, CIFileView.kt:213), and item grouping depends on it (a new timestamp shows when 60 s pass between items or the minute text changes, ChatView.kt:3765-3768, :3784), so masking the time would not keep the rest of the screen stable. A display-clock seam (11.2, approved 2026-10-12) maps every timestamp and "now" used for display to one fixed instant in UI tests: `getTimestampText`, `getTimestampDateText`, `localTimestamp` and `localDate` (ChatModel.kt:3944-3998), `FloatingDate` (ChatView.kt:2894) and the 60 s grouping check (ChatView.kt:3765, :3784); and the SimpleX-name sale countdown, which ticks every second from `System.currentTimeMillis()` (SetSimplexNameView.kt:195-205); P1 audits the remaining display uses of `Clock.System.now()` and `System.currentTimeMillis()` (the chat console is volatile as a whole instead: its lines carry wall-clock times, `TerminalItem.date`, SimpleXAPI.kt:7524, TerminalView.kt:173, and include every core event in arrival order, SimpleXAPI.kt:2905). Times then render as one fixed time and date, and grouping depends only on sender and direction, on every run. The core keeps its real clock, so ordering, TTLs and timers are unaffected. Tests about real time display or time-based grouping (date separators, the floating date, grouping by time; catalogue code `REALTIME`) turn the seam off with `uiTest(realTimes = true)` and save no screenshots, since their screens can't be stable. What remains volatile is masked: QR codes and the link text of invitations and addresses (new keys on every run, QRCode.kt:23-64), security codes, server statistics (ServersSummaryView), call and voice durations, and the looped animations above. These composables get the semantics flag `VolatileKey` through `Modifier.uiVolatile()` (4; added in K0, 5.2), on the volatile child itself; `VolatileKey` does not merge into the parent row or item (4). Before comparing, the harness fills the bounds of every node of the **unmerged** tree with `VolatileKey` with a flat colour in both the old and the new image (union of the old sidecar's and the current masks); a change in the set of volatile nodes (their count or keys, recorded in the sidecar) also counts as a change, so a regression that drops a QR code isn't hidden by the old mask. Where P1 finds that a masked text's width moves its neighbours, the volatile node is widened to cover them. Run-dependent content outside the time display is volatile too: server addresses with their ports (ports come from the JVM's slot, 8.2; the custom servers' editor, ProtocolServerView.kt:181-184, and the server summaries, ServersSummaryView.kt:534, :562) and link-preview URLs (`http://127.0.0.1:`), and the relay list of a channel with several relays, whose order is random (`chooseRandomRelays`, AddChannelView.kt:183-217; ChannelRelaysView.kt:62-79). Ports appear in more places than can be listed reliably (the contact and member "Receiving via"/"Sending via" rows, `SimplexServers`, ChatInfoView.kt:1156-1163, GroupMemberInfoView.kt:659-663; a link-preview test's message text `http://127.0.0.1:/page`, FramedItemView.kt:396-400, and its chat-list preview), so in addition the harness masks, for every text node of the unmerged tree (`Text` or `EditableText`) whose text contains one of the run's own ports (the JVM's server and stub ports, 8.2), whether or not it is flagged, the bounds of its outermost ancestor that merges its descendants' semantics, searching up only to the nearest scroll container (a node with `VerticalScrollAxisRange`; walking to the root would reach full-screen clickable scrims such as the user picker's, UserPicker.desktop.kt:96-100); that is the chat bubble even for a port inside a quote, whose own quote column merges too (FramedItemView.kt:292-308) while the bubble's width follows it, and the whole row for list and info rows; the node itself when there is none (measured on a replica of the item layout: 0 pixels differ): ports change with the JVM's slot between runs and the app font's digits differ in width, so a port's text moves its neighbours (measured: 20001 against 20101 moved an icon after a left-aligned port and a right-aligned bubble's edge by 3 px; with the ancestor masked, 0 pixels differed). Also volatile: incognito profile names, which the core generates at random for each incognito connection (`generateRandomProfile`, Hs ProfileGenerator.hs:9-22) — "Your random profile" in chat info (ChatInfoView.kt:583-589), "Your random profile" in channel info (GroupChatInfoView.kt:628) and the incognito member's own row in group info (:772) and "Join as %s" in a group invitation preview (ChatPreviewView.kt:583-587); the remote-desktop pairing screen's port field, bound to a system-chosen port when none is set (ConnectMobileView.kt:476-485; simplexmq RemoteControl/Discovery.hs:83), and its session verification code (ConnectMobileView.kt:231-238), and on Android the "Use from desktop" session code and pasted invitation (`SessionCodeText`, ConnectDesktopView.kt:341-349, used at :157-160, :273, :326-329; the invitation field, :386-396); the chat console's line list, flagged on the list itself (`LazyColumnWithScrollBar`, TerminalView.kt:159-167, so the number of volatile nodes doesn't vary with the number of lines; each line, :168-174, carries a wall-clock time and core events arrive in their own order); the "Message queue info" alert (shown at ChatInfoView.kt:694-698 and GroupMemberInfoView.kt:717-721 through `showAlertMsg`, text built at ChatInfoView.kt:1399-1403; random queue and message ids and timestamps; `showAlertMsg` gets a volatile-text parameter in K0, AlertManager.kt:212-218); and the chat-relay address editor, whose link has new keys on every run (ChatRelayView.kt:231-241; a preset relay shows its fixed preset address instead). The committed PNG itself is left unmasked, so it reads naturally as documentation. + +**Write only on change.** After capture, if a committed PNG exists with the same size, every pixel outside the masks is identical and the set of volatile nodes matches the sidecar's (tolerance 0 by default; `-PuiShotTolerance=` allows a per-channel delta for other hosts), the file is not touched. Otherwise the new PNG and sidecar are written (each to a temporary file renamed into place, so an interrupted write never leaves a truncated PNG; a committed PNG that can't be decoded (and isn't an LFS pointer) counts as changed) and a diff image (changed pixels in red over a grey copy) goes to `/screenshots/diff//[-].png`. Each JVM appends one line per shot to `/screenshots/summary.txt` (new / changed / unchanged / unsettled / unquiet / orphaned / unverifiable (LFS pointer), with the share of changed pixels); appends are single `write` calls of one line, which do not interleave. Modes, `-PuiShots=`: +- `update` (default): write new and changed screenshots; the test does not fail. `git diff` then shows exactly the screens that changed. +- `verify`: a changed or missing screenshot fails the test, with the diff image in the failure report (7.5) (for PR checks). +- `off`: no capture (quick local runs). +Tests that are `@Ignore`d or skipped by an assumption produce no screenshot and keep their existing ones. Whole-run steps happen in the runner after both passes (7.1.1), only when no test filter was given and the mode is `update`: it deletes committed screenshots of tests that no longer exist (per the catalogue and the compiled classes, not the XML results, which lack entries for a crashed JVM) and those of tests that **passed** this run but didn't produce them (the summary lists every shot each test produced; failing tests save no shots and keep theirs), records them as `orphaned` in the summary, and regenerates the docs index. The traceability check (10.1) fails on screenshots without a catalogue test whenever it runs. + +**Docs index.** A generated `uitest-screenshots/README.md` lists, per catalogue file and sub-area, each row's feature with its screenshots; the runner rewrites it only when its content changes, like the images. + +**Size.** A flat 1600 × 900 UI screenshot compresses to roughly 40–120 KB. The 1,149 runnable tests run 1,185 times (the 36 `S` tests run again in their stable subclasses), so with one or two shots each there are roughly 1,200–2,366 images (the 2 `REALTIME` runs save none), about 50–280 MB in the working tree, and history grows with every changed image. The PNGs are written losslessly with maximum compression and stored with Git LFS (`.gitattributes`: `apps/multiplatform/common/src/desktopTest/uitest-screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text`; sidecars and the index stay in plain git), so clones stay small while PR diffs still show the images. P1 adds the attribute and requires git-lfs (`git lfs install`, which also sets `filter.lfs.required`; it is not installed on this host yet, and without it git stores the PNGs in plain history silently, verified): the runner refuses `uiShots=update` unless `git config filter.lfs.clean` is set and `git check-attr filter` reports `lfs` for a screenshot path (`git lfs env` alone succeeds even without `git lfs install`); no LFS operation needs the network during the run. The compare step reads committed PNGs from the working tree; a checkout without `git lfs pull` has pointer files (they start with `version https://git-lfs`), which `update` never overwrites — such a shot is reported as `unverifiable (LFS pointer)` — and `verify` fails with the same hint. + +### 7.8 Coverage measurement + +(As built: not built; P1b.) + +Decided 2026-10-12: the UI test run measures real coverage, so catalogue completeness is checked against what actually ran rather than estimated. + +- **Tool.** Kover (the Kotlin coverage plugin with the IntelliJ agent) is applied to `:common`. Kover instruments only test tasks of type `KotlinJvmTest` bound to the JVM target, so `desktopUiTest` is registered as `tasks.register("desktopUiTest") { targetName = "desktop"; … }` (7.1). All forks of a pass append to Kover's single per-task binary report (`common/build/kover/bin-reports/desktopUiTest.ic`; the agent appends, so parallel forks share it safely), which Kover deletes when the task starts, so the runner copies it, when present (a pass that is skipped or matches no test writes none), to `/coverage/pass.ic` after each pass and then deletes it (otherwise a later plain `koverHtmlReport`, with `desktopUiTest` skipped, would include stale UI-test coverage) and after both passes merges the pass files that exist (one or two) with a small Gradle task of the harness that reads them through Kover's reporter (resolved in `uitestPrepare`, so it works offline). Because Kover makes `check`, `build` and its own report and verify tasks depend on every instrumented `KotlinJvmTest` of the `desktop` target, `desktopUiTest` runs only when the runner sets `SIMPLEX_UITEST_RUNNER=1` (`onlyIf`, 7.1); a plain `./gradlew check` skips it and never starts the suite outside the namespace. Gradle resolves the plugin when it configures the build (step 1 of 7.1.1 runs outside the namespace), and `uitestPrepare` also resolves the `koverJvmAgent` and `koverJvmReporter` configurations, so the offline run and the merge find them; a run with `-PuiCoverage=off` skips the agent, whose overhead P1 measures. +- **Scope.** The report covers the UI and model code the tests exercise: `chat.simplex.common.views.*`, `chat.simplex.common.ui.*`, `chat.simplex.common.model.*`, `chat.simplex.common.platform.*`, excluding the test harness. Line and branch coverage per package, file and composable function, as HTML and XML in `/coverage/report/`, plus a short summary (per-package percentages and the files with zero covered lines) in `/coverage/summary.txt`. +- **Use.** Files and composables with no covered lines after a full run are the starting list for new catalogue rows: each is either given a row, or recorded in the legend as unreachable on desktop (Android-only, camera, WebRTC) with its reason, so the next full run's zero-coverage list contains only known exclusions. Coverage is reported, not enforced: no threshold fails the run, since screens that need a seam not yet approved (13) or an out-of-reach surface (11.3) can't be covered. +- **Interaction with screenshots.** Coverage runs and screenshot runs are the same runs; coverage adds no files to the repo (reports stay in the run directory). + +## 8. Peers and servers + +### 8.1 CLI peers + +(As built: see 0.5.5. Differs: Current CLI only; args `-d`, `--user-display-name`, `-s ` and the three display flags, with no `--mute`, `--xftp-server` or `--key`; no `$HOME/Downloads`; `TERM=xterm-256color`; `stty -echo -icanon` before exec instead of the start–stop–start cycle; the database comes from the run's peer template (15.1); typing uses a 1.5 s unchanged window and 5 attempts, with no `! `/`> `/`\ ` rule; `sends` consumes the first matching echo anywhere in the buffer; line waits 15 s; no `expectsAny`, `repeatUntil`, `stop`/`start`/`snapshot`/`restore`, relays or mobile hosts; added `eventuallyMessage`, `eventuallyMatching`, `pending`. Matching is shared in `PeerLines`; on Android it runs device-side over the bridge.) + +```kotlin +sealed interface CliVersion { + object Current : CliVersion // built from this checkout + data class Stable(val tag: String = UiEnv.defaultStableTag) : CliVersion +} +sealed interface TestUser { val name: String } +class CliPeer(override val name: String, val version: CliVersion, val flags: PeerFlags, val servers: PeerServers) : TestUser { // flags and servers apply on every start + infix fun sends(cmd: String) // like ##>; types the command with verified input (Typing, below), then waits for the command's echo (the command line itself; for message commands, which the CLI echoes only when they fail, Terminal/Input.hs:64/68/89-99: the sent-item line `HH:MM `, timestamp stripped, View.hs:2427-2429, or, if the command fails, the plain line `` before the error) and re-sends Enter once if the input line still holds it. Some message commands print a sent item that differs from the command, so they are sent with `types` and read with `expectsMessage`/`expects`: a quote reply `> @alice (hi) re` prints `HH:MM @alice > hi` then ` re` (View.hs:2400-2402); a forward `@carol <- @alice hi` prints `HH:MM @carol <- @alice` then ` hi`; text with bold, italic, strike or colour markdown loses those markers (`@alice *b* !1 r!` prints `HH:MM @alice b r`; code and secret keep theirs; View.hs:2461-2462, Styled.hs:38-47; received items the same, View.hs:2362); `/feed ` prints `/feed () HH:MM `, read with `nextLine()` and a prefix check (measured) + infix fun types(text: String) // like Haskell `send`: types the text with verified input (Typing, below) and Enter, waits for no echo; for `/live` (a successful one is not echoed and its item is hidden while live, Terminal/Input.hs:64,91, View.hs:909-911) and for text typed into the live message that follows (the input line is pre-filled with the message, so the verified input line is that text plus the typed text, and Enter ends the live message with the whole line, Input.hs:101-110,186-196; the sender then prints `HH:MM @ [LIVE] `, View.hs:2400-2408, time prefix :2427-2429, which a later `expectsMessage` consumes; if that line never appears and the input row stops changing, the peer has locked up: ending the live message kills the prompt's blink thread, Input.hs:189-190, which may hold the terminal lock while it redraws, and `withTermLock`, Output.hs:138-142, doesn't release it on kill; measured 3 times in 100 Enters sent exactly on the blink, never with ordinary timing; the receiver already has the final text, so the harness restarts the peer with `stop()`/`start()`; the live state is set only after the core returns, which also overwrites the input line, Input.hs:68-72,101-113, so typing into the live message must wait until the receiver, the GUI, shows the LIVE item: typed back-to-back, the text went as a separate message and the live message stayed open, measured; the sender prints nothing to wait for) + infix fun expects(line: String) // like <##: the next output line must equal this (polled, 6) + infix fun expectsMessage(line: String) // like <#: the next line, with the timestamp stripped + fun expectsAny(vararg lines: String) // the next N lines, in any order, like concurrently_ + fun eventually(line: String) // skip lines until one equals this + fun nextLine(timeout: Duration = 10.seconds): String // the next complete output line, returned rather than asserted (JSON responses such as `/_get chat … count=1`, a link the CLI prints for the GUI to paste) + fun repeatUntil(cmd: String, every: Duration, deadline: Duration, done: (String) -> Boolean): String // sends cmd (as `sends`) every `every` until a line printed after it (its response or a later event, before the next send) satisfies `done`, returns that line; fails at the deadline. Catalogue rows that say "repeats until …" use it (e.g. `/_connect plan 1 ` until the AUTH error, `/get servers summary 1` until the single `agent servers summary: {…}` line, about 4,600 characters and so wrapped and joined by the output model, contains `"_connDeleted":1`, a send every 2 s until the AUTH error line) + fun stop(); fun start() // PEERCTL: stop/start the process on the same database; start() returns once the input prompt is drawn + fun snapshot(): PeerSnapshot; fun restore(s: PeerSnapshot) // PEERCTL: copy and restore its database files, including any -journal (a stopped CLI can leave one) + fun contactId(name: String): Long // sends `/i @` (as `sends`), consumes its echo, takes the first later line "contact ID: N" (View.hs:1885; v7.0.3 too) and removes the contiguous block from it through "peer chat protocol version range: …" (View.hs:1898/1945), or, with no active connection, through the last line of that response's batch (one response is printed as one locked batch, Output.hs:311-314; UI theme and custom-data lines, View.hs:1899-1900, would follow, and peers set neither); event lines before it stay for later `expects` +} +data class PeerFlags(val reactions: Boolean = false, val markRead: Boolean = false, val fullLinks: Boolean = false, + val key: String? = null) // `--key`, passed on every start (incl. the names-role restart and `start()`) +data class PeerServers(val smp: String = "smp1", val xftp: String = "xftp1") // `-s` / `--xftp-server` (8.2); `smp2`/`xftp2` run only in tests with code `SMP2`/`XFTP2` +``` + +- **Own directories, per test.** At the end of a passing test every peer and relay is stopped and waited for (`waitFor`, force-killed after a timeout; pty4j's `destroy()` sends SIGTERM to the process group and closes only streams that were never opened, so after `waitFor` the harness closes the pty's input and output streams itself, which frees the master fd; `destroy()` never sends SIGKILL, `destroyForcibly()` does), then its `/t` directory is deleted (a fresh CLI database is about 1.8 MB and a full run starts over 1,000 peers and relays); a deletion error is logged, not failed; a failing test also stops and waits for its peers and relays but keeps the directory for the report. Every CLI process (peers, relays, mobile hosts, fixture and `RelayPeer.run` one-shots) gets `TMPDIR=/tmp` (for fixture one-shots, the directory of the database prefix), created before start: otherwise the agent's XFTP work folder is the system `/tmp` (the CLI passes no temp folder to the agent, Hs/Library/Commands.hs:271-273, so it falls back to `getTemporaryDirectory`, simplexmq Agent/Client.hs:1433-1436; the chat's own fallback is Hs/Files.hs:27; `--temp-folder`, Options.hs:421-426, would set the same in both builds, while `TMPDIR` also covers the chat's own temp use), and moving a received file into `$HOME/Downloads` with `renameFile` (Hs/Library/Subscriber.hs:410) fails with EXDEV when they are on different filesystems — `/tmp` is on the root filesystem here and `/home` on another, and the stable release has no copy fallback (in one experiment a 7.1.0.10 build completed a cross-filesystem receive, mechanism unknown, so the rule holds for every build); it also keeps peer temp files inside `t`. Peers, relays and mobile hosts run with the repo root as working directory, so the catalogue's repo-relative paths (`/f #team tests/fixtures/test.pdf`) resolve: the CLI resolves file paths against its own working directory (Hs/Library/Internal.hs:743-745), and the test JVM's is `common/`. Each peer runs with `HOME=/t/peers/` (`n` = the test's index in the JVM; a peer reused across tests would reopen a database that already has a contact `alice` from the previous test, and the new contact would become `alice_1`); `stop()`/`start()` inside one test keep the directory. `$HOME/Downloads` is created before the first start (received files go there only if it exists, otherwise to the temp directory, i.e. `TMPDIR`, Files.hs:29-32), and `-d` is always given (the default database would be the real `~/.simplex`, Terminal/Main.hs:26). Every peer also gets `--user-display-name ` and `--mute` (the network namespace doesn't isolate D-Bus, so unmuted peers would send desktop notifications). +- **Terminal only.** The CLI runs the terminal UI, the `-p` WebSocket server, the headless mode or a single `-e` command (`src/Simplex/Chat/Terminal/Main.hs:31-43`), so interactive peers are terminal peers; structured data comes from typing `/_get …` and similar commands. +- **Display flags.** The real CLI's defaults differ from the Haskell test client's (`testView`, `showReactions`, `markRead`, `showFullLinks` are on in tests/ChatClient.hs:184-189, `testView` at :308, and off in the CLI: src/Simplex/Chat.hs:117-123, with `--mark-read` an off-by-default switch, Options.hs:458-462). `testView` has no CLI option, so catalogue rows quote only lines the real CLI prints. The others are per-peer `PeerFlags`, set by `cli(name, flags = …)`: `--reactions` (received reactions are printed), `-r/--mark-read` (shown items are marked read, which starts a received timed message's timer), `--show-full-links` (full links, the "… link for old clients" lines; the option exists only in the current build: v7.0.3 has no such option and prints those lines unconditionally, View.hs:1053/1125/1187 at the tag, so `cli` passes the flag only to current-build peers). Rows that need a flag name it in their notes. +- **pty.** The CLI needs a terminal (without one it crashes with `Prelude.undefined` from the terminal library), so peers run under pty4j with a fixed size: 4000 × 50, so that most output lines don't wrap (P1 checks that the longest full link fits; longer lines — JSON responses such as `/_get chat … count=1` — are joined by the output model below; a zero-size pty makes the CLI fail with `divide by zero`). +- **Output model.** The CLI draws with absolute cursor positioning: it moves the cursor above the input line, writes the message, erases to end of line and lets the terminal scroll (Terminal/Output.hs:321-380), and it redraws the input line on every keystroke. The peer runs a VT screen model (cursor positioning, erase sequences, `\r`, scrolling) that keeps scrolled-off history and yields each output line once it is complete above the input line, with prompt prefixes removed and lines the terminal soft-wrapped at the pty width joined back (the model tracks the wrap flag of each row: a row is marked wrapped only when a printable character arrives with the last column already filled, so a line of exactly the pty width followed by erase and CR LF is not joined, and an erase while that wrap is pending keeps the last column, unlike xterm, which would lose the line's last character; verified against the stable CLI's 4,197-character `/_get chat` line); it counts one cell per code point, as the CLI positions text by character count (Output.hs:321-375), and ignores colour and cursor show/hide sequences; each peer keeps a cursor, as `getTermLine` does. P1 tests the model against the CLI's own output. +- **Typing.** The CLI loses typed characters while it prints: every output event, including ones that print nothing (the sender's own status and receipt events, Terminal/Output.hs:144-158), runs `withTerminal` (Output.hs:92,97-98,311-319), and haskell-terminal starts a stdin reader for each such call that reads into a slot nobody empties and is cancelled when the call ends (terminal `Platform.hsc:58-70,149-166`); each event can take up to 2 characters, Enter included. Measured: 200 sends of about 85 characters while receipts arrived, 14 echoes didn't match and 2 had none; with no events arriving, 0 of 200. So `sends`, `types` and `contactId` write the text without Enter and poll the VT model's input line (the row after the prompt, which is `> `, or `> [LIVE] ` / `> [ ] ` while a live message is open, switching every second, Input.hs:112-113) until it equals the text; if it is unchanged for 200 ms and still differs (each keystroke redraws the whole line, Output.hs:336, so there is no short absolute deadline), they erase with Backspace, sent as DEL `0x7f`, the pty's erase character (`0x08` is read as Ctrl-H plus forward Delete, which erases nothing at the end of the line: haskell-terminal Decoder.hs:27, Platform.hsc:107; only VERASE maps to Backspace, Platform.hsc:104), until the line is empty (a stolen first character can leave a line longer than the text, because typing into an empty line inserts the active chat prefix, Input.hs:351-357), or in a live message down to the pre-filled text (see `types`), and retype (bounded retries, then the test fails). Only then they write Enter; `sends` then waits for the echo, and both `sends` and `types` wait until the input line no longer holds the typed text after either prompt (plain `> `, or the live prompt while a live message is open) and re-send Enter if it still does after about 500 ms (safe for `/live`: after its Enter the line is empty and then holds the pre-filled message, Input.hs:110, never the typed command; a successful Enter in a live message clears the line and sets the plain prompt at once, Input.hs:208,221) (an extra Enter on an empty line outside a live message does nothing, Input.hs:177, and submitting clears the line at once, Input.hs:215-221, so this can't submit twice). The CLI also inserts the active chat while keys arrive (Input.hs:234, 351-357): into an empty line for any first character except `@ # / > \ ! + -`, and after a space typed right after a lone `!`, `>` or `\` (the active chat is set by sending, reading, editing or deleting, Input.hs:78-81, 84, by received messages that notify, contact events and joining a group, Output.hs:178-180,189,191,194,202, and unset at Output.hs:196 and Input.hs:76, 82-83, 86; format Output.hs:236-252). So texts are written with an explicit chat (`@alice hi`), and for a text that starts with `! `, `> ` or `\ ` the peer types those two characters, waits until the input line starts with them (the CLI inserts the chat in the same redraw as the space), erases with DEL whatever follows them, then types the rest; the input-line check expects the full text. Measured over the same 200 sends: 0 corrupted, 0 missing echoes, 18 retypes; typing into a live message the moment the receiver shows it lost 2 characters in 7 of 60 runs (the live prompt's blink thread starts redrawing then, Input.hs:106,120-126), and with this rule 20 of 20 runs with a deliberately dropped character ended with the right text. +- **Consuming output.** As in the Haskell tests, each `expects` consumes one line; helpers such as `connect()` consume the lines they cause. Lines a test doesn't care about are skipped with `eventually`. +- **Contact ids differ from the Haskell tests.** A user created through `CreateActiveUser` — every real CLI user (`--user-display-name` or the prompt, Hs/Core.hs:151-157, :177) and the GUI's user (`apiCreateActiveUser` in `resetForTest`) — also gets the preset "Ask SimpleX Team" contact card and a notes folder (Hs/Library/Commands.hs:466-478; v7.0.3 too), so contact 2 is the card and the first real contact is 3; the Haskell test client creates users with `createUserRecordAt` (tests/ChatClient.hs:366) and has no card. Catalogue rows therefore use name commands where they exist (`/d alice`, `/set disappear @alice yes 3`, `@#team @alice `, `/call @alice`, `! @alice `, `/accept member #team @alice`), and `contactId(name)` for API commands that take only ids (`/_call end @`, `/_get content types @`, `/_send @ json …`, `/_set prefs @ …`). On the GUI the card is in `chatModel.chats` but hidden from the list (`filteredChats`, ChatListView.kt:1554; `isContactCard`, ChatModel.kt:1933); with the notes folder it still counts wherever the app counts `chatModel.chats` (the SimpleX Lock notice, App.kt:100, and the update notice, desktop Main.kt:69-73) and blocks the "You have no chats" state (ChatListView.kt:458), so catalogue 01 `youHaveNoChatsEmptyState` uses a fixture. +- **Names only.** The GUI user and peers have display names only, so CLI lines read `alice: contact is connected`; a full name, description or badge would add text (View.hs:3022-3034). +- `CliVersion.Current` = the CLI built by the runner (`cabal build -f uitest --builddir=dist-uitest exe:simplex-chat`, 7.1.1). +- `CliVersion.Stable(tag)` = the release binary from the GitHub release `tag`: asset `simplex-chat-ubuntu-22_04-x86_64` for v6.4.2 and later, `simplex-chat-ubuntu-22_04-x86-64` for earlier tags (the fixture CLI v6.3.7 included; the other name returns 404), checked against the release's `_sha256sums`. The tag is a Gradle property, `-PuiStableTag=` (one per run), with the default `v7.0.3` pinned in `build.gradle.kts` (GitHub's "Latest" release on 2026-10-10; v7.0.4 is a pre-release) and bumped by hand; to test another release, run again with another tag. `uitestPrepare` downloads it outside the namespace into `~/.cache/simplex-uitest/cli//`, verifies the published SHA-256, and writes the path into `env.properties`. The binary needs glibc 2.34 (the host has 2.36); it reports its own version (v7.0.3 prints `v7.0.0.12`), so tests never assert the tag in CLI output. A stable CLI can't be a remote host (`minRemoteHostVersion` 7.1.0.9, Remote.hs:81-82). +- **Relays and mobile hosts** are CLI processes too. Relays run without a pty: `--relay --headless --user-display-name -d -s --xftp-server ` (headless creates the relay address on first start and prints `Chat relay address is created:` followed by the link, Hs/Core.hs:190-196; `--relay-web-domain` and `--relay-web-dir` must be given together, Options.hs:295-298); the harness reads the link and registers the relay in the GUI's servers (or the settings test does it through the UI). A headless relay takes no typed commands, so `RelayPeer.run(cmd)` stops the relay, runs the command once with `-e -t 3` and the running relay's `--relay`, `-s`, `--xftp-server` and `-d` options on its database, with stdin `/dev/null` (`-t` is the wait after the command, default 3 s, Options.hs:387-395, used at Terminal/Main.hs:61; the process must stay alive after the command returns, because async effects such as `/da`'s server-side deletion run in a background thread nothing waits for, simplexmq Agent.hs:1013, and after a restart the agent retries them only from its cleanup task 30 s later, Env/SQLite.hs:247 — with `-t 0` the deletion reached the server in 0 of 4 runs, with `-t 3` in 4 of 4), and starts it again (`PEERCTL`). A headless relay that has no address after the command (e.g. after `/da`) creates and prints a new one on start, and `RelayPeer.link` is refreshed from that output. `relay(…)` and `start()` wait for the `Chat relay address is created:` line (on later starts, `Current user: `, Hs/Core.hs:87, the only line a headless relay prints then after the welcome banner, Terminal/Main.hs:38-41; a `start()` after a `run()` that deleted the address waits for the address line instead) with their own 60 s timeout (first start measured 4–8 s, slower while peers start). Relays are usable right after that line: messages wait on the SMP server until the relay subscribes (measured: delivery 0.41–0.43 s after a post sent the instant `Current user:` appeared, 0.35 s in steady state). Each relay restart logs `internal error: SEMsgNotFound "setMsgUserAck"`, which is harmless and must not fail a test that checks relay output for errors. Relay directories are per test, like peers'. Relays have no pty and don't read stdin, so they are started under `setpriv --pdeathsig KILL` and die with their JVM even when it is killed (the parent-death signal fires when the *thread* that spawned the process ends, so the harness spawns them from one daemon thread that lives as long as the JVM). Every CLI process (peers, relays, mobile hosts, fixture and `RelayPeer.run` one-shots) gets `TERM=xterm` and `LC_ALL=C.UTF-8` in its environment (in the C locale an emoji typed into the CLI silently kills its input thread, and one it prints kills it with `commitAndReleaseBuffer: invalid argument`, measured; emoji reach peers in normal rows, e.g. reaction lines; the host has `C.utf8`, and P1 asserts it); pty peers end with their pty when the JVM dies. A remote-desktop mobile host is a current-build terminal peer (catalogue 04 §K). +- Peers that use SimpleX names need the names server role, which `-s` doesn't enable on the first start (Operators.hs:189-193). The Haskell tests enable it with `/_servers 1 ` (`enableNamesRole`, tests/ChatClient.hs:487-505), but the real CLI prints `/_servers` as text without server ids (View.hs:139, :1664-1690), so the harness has no JSON to edit. Instead `cli(…)` (and so `mobileHost(…)`) starts every peer once, stops it and starts it again before the test sees it: on every later start `-s` sets the servers with all roles, names included (src/Simplex/Chat.hs:300-304), so every peer on `smp1` (where the resolver is) can resolve names; no catalogue row depends on a peer failing to resolve one. The restart is needed for a second reason, so it stays even if the names role is set another way: the same per-event `withTerminal` calls save and restore the terminal settings, and on a peer's very first start this can leave the pty in echo mode, so every typed command comes back as an extra output line (measured: 8 of 21 and 5 of 12 first starts; 0 of 96 restarts). In addition `start()` clears ECHO and ICANON on the pty through its master fd (pty4j `UnixPtyProcess.getPty().getMasterFD()`; `tcsetattr` on the master changes the slave's flags, measured), and P1 checks that a started peer's pty does not echo. A new peer's first start creates its database and takes about 4–15 s depending on load (measured: one-shot 3.8–4.6 s; under a pty 4.6–11.2 s alone, 7.0–14.6 s with four in parallel; a restart 0.1–0.4 s), so `cli(…)` returns at once and does the first start, stop and restart in the background; the peer's first `sends`/`types`/`expects` waits for it, so a test's peers start in parallel (that wait has its own 30 s deadline, measured with the start–stop–start cycle in parallel: 9 peers 11.3 s, 21 peers 15.7 s, 20.6 s with 2 cores busy; the 9-peer member-list test would otherwise spend 45–110 s of its 2-minute deadline starting peers). +- Kotlin can't define `##>` (`<` and `>` aren't allowed in JVM method names), so infix words keep the same shape. + +### 8.2 Servers: `uitest-servers` + +(As built: see 0.5.6. Differs: `smp1` only, args ` `, no control protocol or names resolver, no `restoreBaseline`; `xftp1` is configured in the GUI but not served (P1b); the process is spawned from the test thread during the first reset (bridge: its main thread), not from a dedicated daemon thread.) + +A test-only executable component in `simplex-chat.cabal`, built only with a manual flag: + +``` +flag uitest + manual: True + default: False + +executable uitest-servers + if !flag(uitest) || flag(client_library) + buildable: False + main-is: UITestServers.hs + hs-source-dirs: tests + other-modules: ChatClient, ChatTests.DBUtils, ChatTests.DBUtils.SQLite, NameResolver + default-extensions: StrictData (as in the test-suite) + ghc-options: -threaded + build-depends: (the test-suite's build-depends, incl. hspec with the same GHC conditional, wai, warp, http-types, sqlcipher-simple) + (client_postgres conditions copied from the test-suite: -DdbPostgres and ChatTests.DBUtils.Postgres; cabal-version 1.12 has no common stanzas) +``` + +The default-off flag keeps CI's `cabal build --enable-tests` from building it. `UITestServers.hs` reuses the server configurations of the Haskell tests in `tests/ChatClient.hs` (no simplexmq test modules are needed; the server code is in the simplexmq library), but runs each server as an `async` instead of the `with*` brackets, so a server can be stopped and started (starting an XFTP server first creates its `filesPath` with all parent directories (`createDirectoryIfMissing True`; `withXFTPServer'`, tests/ChatClient.hs:752-757, creates only the last directory), since the server doesn't create it and `reset` deletes the server's whole directory): + +- **Per server id** (`smp1`, `smp2`, `xftp1`, `xftp2`) its own `TestParams`: `tmpPath = /servers/` (created first), `portBase = base + 10 × index` (smp binds portBase+1, xftp portBase+2, ChatClient.hs:638/:709), and query-stats maps from `TM.emptyIO` (tests/ChatTests/DBUtils/SQLite.hs:7-13). +- SMP: `smpServerCfg` (:635-685) with `serverStoreCfg = persistentServerStoreCfg` (:687-688; the default is `SSCMemory Nothing`, :645), so a stop/start keeps queues. The test server's message quota is 16 per queue (:640); `PEERCTL` tests send fewer than 16 messages to a stopped peer, except catalogue 02 `status_quotaExceeded_warningAlert`, which sends 17 to reach the quota. +- XFTP: `xftpServerConfig` (:706-746), files, store log and stats under the server's own `tmpPath`. +- **Distinct hosts.** `/_servers` rejects two servers with the same host (`USEDuplicateServer`, Operators.hs:555-562, compares the host only), so `smp1`/`xftp1` use `localhost` and `smp2`/`xftp2` use `127.0.0.1` (as tests/ChatTests/Profiles.hs:3240 does); the servers bind dual-stack, and clients check the CA fingerprint, not the host name. +- **Names.** One `NameResolver` (Warp, tests/NameResolver.hs) runs for the process lifetime and `smp1` is started with `resolverNamesConfig` (NameResolver.hs:86) pointing at it, so registrations survive server restarts within a test. Its registry is an in-memory map with insert only (NameResolver.hs:37-55); `UITestServers.hs` adds a `clear-names` command that empties it, and `restoreBaseline()` sends it in every reset, so one test's registrations can't change another's result. The GUI's servers get the names role only in `NAMES` tests. +- TLS: `tests/fixtures/tls/{ca.crt,server.crt,server.key}`; the harness starts the process with the repo root as working directory (the configs use relative paths). The fingerprint is fixed (`LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=`). +- **Ports.** Each JVM takes the first free slot `k` (0 ≤ k < 120) by holding an exclusive lock (`FileChannel.tryLock`, an fcntl record lock; the channel and lock are kept in a static field, since a collected channel closes and releases the lock, and the slot file is never opened again in that JVM, as closing any descriptor of the file drops the lock) on `slot-.lock` in the run directory for its lifetime (the runner creates a fresh run directory for each invocation, 7.1.1; a slot is released when its JVM exits, and a JVM shutdown hook first stops its peers and relays, closes `uitest-servers`' stdin and waits for it to exit, at most 10 s, and kills any remaining descendant processes (e.g. the `gsettings monitor` the theme detector spawns, which otherwise outlives every GUI JVM), so the next JVM on the slot never meets the old servers on its ports; `uitest-servers` is spawned like the relays, under `setpriv --pdeathsig KILL` from the same daemon thread, 8.1, which covers a JVM that is killed), and gives the process `base = 20000 + 100 × k`, so every port stays below the namespace's ephemeral range (32768–60999), where a fixed bind could collide with an outgoing connection. Other fixed ports a test opens (HTTP and SOCKS stubs, a chosen remote-desktop port) come from the same block. + +Control protocol on stdin/stdout, one line per command, every command run under `try`: + +``` +start smp|xftp → ready +stop → stopped (after the server has saved its store: stop = cancel, which waits) +reset → ready (stop, delete the store, start: "restarted without its store") +register [contact=] [channel=] → registered +clear-names → cleared +(any failure) → error +``` + +The process sets `LineBuffering` on stdout (a pipe is block-buffered by default), and the harness ignores output lines that aren't control responses (server logs). It starts `smp1` and `xftp1` at launch and exits on stdin EOF (each SMP server replaces the process's SIGINT handler, so the harness ends it by closing stdin, then, after 10 s, SIGKILL from the shutdown hook's `destroyForcibly()` (7.1)). `TestServers.restoreBaseline()` in each reset starts or resets any server the previous test stopped or reset, stops `smp2`/`xftp2` if they ran, and clears the names registry. `smp2`/`xftp2` are started only for tests that need them (catalogue codes `SMP2`, `XFTP2`). + +`TestServers.configure(user)` sends `/_servers ` with the preset operator groups `enabled: false` and one custom group with `smp1` and `xftp1`; this needs no conditions acceptance (`APISetUserServers`, Library/Commands.hs:1805-1819, and `validateUserServers`, Operators.hs:537-596, never check it; disabled groups are skipped through `opEnabled`, :544, :592-593). The preset operators are global rows created at chat start (src/Simplex/Chat.hs:295) and enabled by default (Presets.hs:27/41). `configure` runs right after `startChat` (the core rejects `/_servers` with `chatNotStarted` before it, found in P1a); a fresh user has no connections, so nothing connects in between; a fixture database that already has a user is started by `initChatController` before `configure` (7.2), which is harmless because the run has no network and fixtures are created against the local servers (11.1). In `NAMES` tests the custom group's `smp1` also gets the names role (see **Names** above). + +This replaces the `smp-mini` approach: the `smp-server` executable doesn't build from the cabal tarball because the TH `embedDir` of `.well-known` is missing from `extra-source-files`. + +### 8.3 Network isolation + +(As built: as below for desktop (0.5.9); peers have no `--mute` yet, so the D-Bus point stands. The Android runner does not apply `wd.sh net isolate`, open item, 0.5.9.) + +The Gradle run executes in an unprivileged user, network and pid namespace with only loopback (runner script, 7.1.1). Everything it starts (test JVMs, CLI peers, relays, `uitest-servers`) inherits it. Checked on this host: loopback works, `localhost` resolves from `/etc/hosts`, public DNS and every public address fail with "Network is unreachable". + +- The isolation is network-only: D-Bus and X11 sockets are files and stay reachable, hence `--mute` for peers (8.1) and the recording notification manager for the GUI. +- Tests can never reach public servers by accident, including the preset operators' servers. +- Onboarding needs no core change: onboarding opens no connection (the conditions text is built into the core); after onboarding, the test calls `GuiUser.useLocalServers()` (same `/_servers` call as `configure`) before anything connects. `acceptConditions` re-enables the preset operators during onboarding (ChooseServerOperators.kt:301-305), which is why the call comes after "Chats" appears. +- Remote desktop ("Link a mobile") works over loopback: simplexmq's `getLocalAddress` keeps 127.x addresses (ordering them last), and ConnectMobileView.kt:526 keeps `127.0.0.1` when it is the first address — in the namespace it is the only one. +- Multicast discovery works on loopback with `ip route add 224.0.0.0/4 dev lo src 127.0.0.1` (without `src` the packet's source is 0.0.0.0 and simplexmq rejects it because the source must equal the invitation host, RemoteControl/Client.hs:387-389). All JVMs share one namespace and UDP port 5227, so multicast tests are in the `Multicast` category and run in the serial second pass (7.1.1). A multicast reconnect needs a stored pairing first (new hosts are refused over multicast, RemoteControl/Client.hs:118). (`ip link set lo multicast on`, also run, is not needed on kernel 6.18 but harmless; measured with a copy of simplexmq's Discovery.hs listener and sender: both group joins succeed and receive with source 127.0.0.1) +- Inside the namespace every process runs as uid 0 of the user namespace; files it creates belong to the real user outside. +- Link previews use `http://127.0.0.1:` pages from the local HTTP server; `localhost` URLs are not parsed as links by the core (Markdown.hs:388). + +## 9. Screen objects and the DSL + +### 9.1 Screen objects + +(As built: see 0.5.1. Built screens: `App`, `ChatListScreen`, `NewChatScreen`, `UserPickerScreen`, `ProfilesScreen`, `AddressScreen`, `AppearanceScreen`, `ProfileScreen`, `ChatScreen`, `ContactInfoScreen`, `GroupInfoScreen`, `MemberInfoScreen`, plus the chat-list, onboarding and What's New helpers; no `GuiUser`, `ChatRow`, `ContactRequestRow`, `GroupInvitationRow`, `ConnectPlanAlert` or `RelayPeer`; `ChatListScreen.row()` returns the screen.) + +```kotlin +class GuiUser(override val name: String, internal val d: UiDriver) : TestUser { + val chatList get() = ChatListScreen(d) + fun userPicker(): UserPickerScreen = chatList.openUserPicker() + fun settings(): SettingsScreen = userPicker().settings() + fun newChat(): NewChatSheet = chatList.newChat() + fun openChat(name: String, block: ChatScreen.() -> Unit = {}): ChatScreen = chatList.row(name).open(block) + fun useLocalServers() // after onboarding (8.3) + fun console(cmd: String): List // Developer tools → Chat console, for setup steps the UI has no button for +} +class ChatListScreen(private val d: UiDriver) { + fun row(name: String): ChatRow // resolves name → chat id from chatModel, waits for exactly one match + fun request(name: String): ContactRequestRow // a contact with a pending request ("Open to accept"; accept/reject through the row menu) + fun invitation(group: String): GroupInvitationRow // tap opens the "Join group?" alert (ChatListNavLinkView.kt:196-199) + fun openUserPicker(): UserPickerScreen + fun newChat(): NewChatSheet + fun pasteLink(link: String): ConnectPlanAlert +} +class ChatRow(private val d: UiDriver, val key: UiKey) { + val unread: UiUnread; val incognito: Boolean; val node: UiNode + fun open(block: ChatScreen.() -> Unit = {}): ChatScreen; fun menu(action: String) +} +class ContactRequestRow(private val d: UiDriver, val key: UiKey) { val node: UiNode; fun accept(incognito: Boolean = false): ChatRow; fun reject() } +class GroupInvitationRow(private val d: UiDriver, val key: UiKey) { fun join(): ChatRow; fun delete() } +class ChatScreen(private val d: UiDriver) { + val title: UiNode; val banner: UiNode + val messageText: String; val placeholder: String // the compose field's text and placeholder, through UiDriver.composeField() (7.4); on Android read from its EditText (catalogue 05 setup item 5) + fun send(text: String) // Ui.Chat.Message + Ui.Chat.Send + fun item(text: String): ChatItemNode // text matched inside chat.item[…] nodes only + fun shouldSeeMessage(text: String) // waits for an item with this text + fun info(): ChatInfoScreen +} +class ChatItemNode(private val n: UiNode) { val sender: String?; val meta: UiItemMeta; fun menu(action: String) } +class NewChatSheet(private val d: UiDriver) { fun createOneTimeLink(): String; fun createGroup(name: String): ChatScreen; fun createChannel(name: String): ChatScreen } +class ConnectPlanAlert(private val d: UiDriver) { val profileName: String; fun openNewChat(): ChatScreen } +class UserPickerScreen(private val d: UiDriver) { fun address(): AddressScreen; fun settings(): SettingsScreen; fun profiles(): ProfilesScreen } +class SettingsScreen(private val d: UiDriver) +class AddressScreen(private val d: UiDriver) { fun create(): String; fun businessAddress(on: Boolean) } +class ChatInfoScreen(private val d: UiDriver) // members added by the sub-areas that use them +class ProfilesScreen(private val d: UiDriver) +class RelayPeer(val name: String) { val link: String; fun run(cmd: String): List; fun stop(); fun start() } // 8.1 +``` + +The address screen is reached from the user picker ("Create SimpleX address" / "Your SimpleX address", UserPicker.kt:220-223); Settings has no address entry. Names resolve to ids through `chatModel` (the test runs in the same JVM), so a test says `row("bob")` while the driver finds `chatList.row[@12]` regardless of list order. Each catalogue sub-area adds the screen objects its tests need. + +### 9.2 DSL entry + +(As built: `uiTest { }` takes no parameters; `app(user: String?): App` (`null` = onboarding boot); `restart(): App`; `cli(name, flags)`; `connect(peer)` and the other helpers of 0.5.7. `app()` is not limited to one call per test. Not built: `realTimes`, `fixture`, `relay`, `mobileHost`, `servers`, `allowUncaught`, `screenshot`, `shouldHave`, `peerVersion`.) + +```kotlin +abstract class UiTest { // base class: per-JVM setup in its companion, watchdog, report on failure + open val peerVersion: CliVersion = CliVersion.Current // `…CompatStableTest` subclasses override it (10.1) + fun uiTest(realTimes: Boolean = false, block: UiTestScope.() -> Unit) // realTimes: display clock off (7.7) + inner class UiTestScope { + fun app(name: String, onboarded: Boolean = true, fixture: ((String) -> Unit)? = null): GuiUser // runs resetForTest; first call, once per test + fun cli(name: String, version: CliVersion = peerVersion, flags: PeerFlags = PeerFlags(), servers: PeerServers = PeerServers()): CliPeer // 8.1 + fun relay(name: String, web: Boolean = false, register: Boolean = true): RelayPeer // headless relay; registered in the GUI's servers unless register = false (relay-settings tests add it through the UI); run(cmd) via stop + `-e` + start + fun mobileHost(name: String): CliPeer // current-build CLI acting as the remote-desktop host, created as cli(name, CliVersion.Current) (the start–stop–start cycle of 8.1 included) + val servers: TestServers // stop/start/reset (SRVCTL), register names (NAMES) + fun restart(fixture: ((dbPrefix: String) -> Unit)? = null) // RESTART: restartCore(fixture) (7.3) + fun connect(gui: GuiUser, peer: CliPeer) // one-time link from the GUI, joined by the peer; consumes its lines + fun allowUncaught(type: KClass, block: () -> Unit) // a step expected to raise an uncaught app error of this type (6) + fun screenshot(name: String, of: UiNode? = null) // named shot (7.7); every passing catalogue test also saves one at its end (except realTimes tests and -PuiShots=off); in a realTimes test it fails + infix fun UiNode.shouldHave(m: SemanticsMatcher); infix fun ChatRow.shouldHave(m: SemanticsMatcher) + infix fun T.shouldBe(expected: T) + } +} +``` + +`app()` must be the first call of a test and may be called once; a second call fails. Across tests the JVM is reused through `resetForTest` (7.3). + +### 9.3 Example tests + +(As built: the three tests exist as `OneTimeLinkCompatTest`, `AddressCompatTest` and `JoiningCompatTest` without stable subclasses. `JoiningCompatTest` reads `connect(bob)`, `alice.chatList.open("team")`, `click(Ui.Alert.Confirm(Ui.AlertSource.Shared))`, `bob.eventually("#team: alice joined the group")`.) + +These are catalogue rows written in the DSL (the names are catalogue names). All three rows include `S`, so their tests live in `…CompatTest` classes with a stable subclass (10.1). + +```kotlin +package chat.simplex.app.ui.main + +open class OneTimeLinkCompatTest : UiTest() { // catalogue 01, "1-time link" + @Test fun oneTimeLinkCliConnectsViaShortLink() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val link = alice.newChat().createOneTimeLink() + bob sends "/c $link"; bob expects "confirmation sent!" + bob expects "alice: contact is connected" + alice.chatList.row("bob") // the pending row has become contact "bob" + } +} +@Category(StableCompat::class) +class OneTimeLinkCompatStableTest : OneTimeLinkCompatTest() { override val peerVersion: CliVersion = CliVersion.Stable() } + +open class AddressCompatTest : UiTest() { // catalogue 01, "Address" + @Test fun addressCliConnectsAndRequestAppears() = uiTest { + val alice = app("alice"); val bob = cli("bob") + val address = alice.userPicker().address().create() + bob sends "/c $address"; bob expects "connection request sent!" + alice.chatList.request("bob") // contact row with "Open to accept" (accepting is another row's test) + } +} +@Category(StableCompat::class) +class AddressCompatStableTest : AddressCompatTest() { override val peerVersion: CliVersion = CliVersion.Stable() } +``` + +```kotlin +package chat.simplex.app.ui.groups + +open class JoiningCompatTest : UiTest() { // catalogue 03, "Joining" + @Test fun groupInvitation_tapRowShowsJoinAlert() = uiTest { + val alice = app("alice"); val bob = cli("bob") // bob is the group host + connect(alice, bob) + bob sends "/g team"; bob expects "group #team is created" + bob expects "to add members use /a team or /create link #team" + bob sends "/a team alice"; bob expects "invitation to join the group #team sent to alice" + alice.chatList.invitation("team").join() // tap the row → «Join group?» → «Join» + bob expects "#team: alice joined the group" + } +} +@Category(StableCompat::class) +class JoiningCompatStableTest : JoiningCompatTest() { override val peerVersion: CliVersion = CliVersion.Stable() } +``` + +Each test asserts what its catalogue row lists; the bodies above show the shape. + +### 9.4 Key lint + +(As built: not built. Superseded for now by the 2026-10-14 minimal-app-code rule (13), which makes "keys everywhere" a non-goal.) + +A UI test that opens every screen reachable through the screen objects and fails if any node with a click action has no `TestTag` (a semantics walk: `SemanticsActions.OnClick` present, `TestTag` absent), with two exemptions no `Modifier.ui` can reach: `LinkTestMarker` link nodes whose nearest ancestor carrying a `TestTag` in the merged tree is their `Text` or the row it merges into (3.5), and the `ModalBottomSheetLayout` scrim that Material adds while a sheet is shown (matched as `OnClick`, no role, no `TestTag`, content description ["Close sheet"] and no children, not by bounds: start-modal sheets are 388 dp wide, measured; CreateProfile's image sheet, WelcomeView.kt:73; the profile editor's, UserProfileView.kt:80; AddGroupView.kt:88, AddChannelView.kt:248, GroupProfileView.kt:89, the attach sheet ChatView.kt:990). Screens that can't be reached in a test — composables not reachable from the UI (`BadgesYourLevelView` and `BadgesPayView`, behind the uncalled `ChooseLevelButton`, BadgesSupportSimplexView.kt:77) and the badge-display screens, which need a badge credential the harness doesn't have — are excluded; their keys are added anyway. It keeps "keys everywhere" true once all areas are keyed. A second, source-level check lists the top-level `var`s of the app's UI files (commonMain and desktopMain `views/`), and the top-level `val`s there whose type is mutable (`MutableState`, `MutableStateFlow`, mutable collections; e.g. `chatViewScrollState`, ChatView.kt:3230; `connections`, CallView.desktop.kt:28; `failedComposables`, Utils.kt:633), and fails on any that is not in the reset list of `clearUiState` (7.3) or an explicit allow-list (extension properties without a backing field hold no state and are skipped, 7.3), so file-level UI state added later is reset too. + +## 10. Test catalogue (all screens, all features) + +The catalogue is the definition of "thorough": one row per user-facing feature, with the entry path (exact `strings.xml` labels), the main file:line, the test method names, what the GUI must show and what the CLI peer must print (with the matching Haskell test), what the test needs, and notes. `00-legend.md` holds the shared format, Needs codes, output conventions, the owner of each overlapping feature and the class map (sub-area → test class and package). + +| File | Area | Sub-areas | Tests | Ignored | +|---|---|---|---|---| +| `01-onboarding-chatlist-connect-profiles.md` | Onboarding, What's New, chat list (search, filters, lists, rows, row menus, requests, pending connections, cards), servers summary, user picker and chat profiles, new chat sheet, 1-time links, contact/address connection plans, prepared direct chats, pending connection info, address and business-address settings, SimpleX name, contact info, preferences, delete-contact variants | 15 | 352 | 0 | +| `02-chat-view-messages.md` | Chat view shell and toolbar, composing (markdown, links, previews, mentions, bot commands), live and timed messages, voice, attachments, replies, edit and history, delete and rendering of moderated items, multi-select, forwarding, reactions, copy/share/save, member-side reports, item info, unread and scrolling, search and content filters, drafts, status icons, special items, notes, context menus and keys | 24 | 281 | 4 | +| `03-groups-channels-business.md` | Create group, invite, group info rows, profile and welcome message, group preferences, member admission and review, group link, joining and group/channel/business plans, member list and multi-select, member info, member contacts, support chats, moderator-side reports, moderation, mentions-only notifications, events and history, leave/delete/clear, channels as owner and as subscriber, business chats from both sides | 20 | 312 | 4 | +| `04-settings-database-remote.md` | Settings root, privacy, SimpleX Lock and self-destruct, appearance, network and servers, calls settings, chat data and database, migration, version and developer tools, help, startup notices, remote desktop "Link a mobile" (desktop is the controller, a CLI is the host), supporter-perks screens without badge display | 13 | 232 | 20 | +| | | **72** | **1,177** | **28** | + +The four catalogue files and the legend live in `plans/ui-tests/catalogue/` while the plan is in review; P1 moves all five into the repo at `apps/multiplatform/common/src/desktopTest/resources/uitest-catalog/` (with `05-android-only.md` and `05-android-carryover.md` beside them, for the Android harness) (their paths are already repo-relative), and tests read them from the classpath. + +### 10.1 Rules + +(As built: none of the category, Compat-stable or traceability machinery is built (P1b). Tests outside these rules exist: desktop-only tests in `chat.simplex.app.ui.desktop` (`OnboardingTest`, `…DesktopTest`) and Android-only tests in `chat.simplex.app.ui` and `chat.simplex.app.ui.back`; the traceability check needs a rule for them before it is built.) + +- **Classes.** Each sub-area maps to one test class, or to several consecutive classes when it has more than 30 tests (82 class bases in all; the legend's class map lists each base and its first test; the package is `chat.simplex.app.ui.` for catalogues 01–04, legend rules). A base with `S` tests has, for those tests, `open class CompatTest` and `@Category(StableCompat::class) class CompatStableTest : CompatTest()`, which overrides `peerVersion`; its other tests are in `Test`. JUnit 4 runs the Compat tests once per peer version. Test method names are the catalogue names. `S` is never combined with `RELAY`. +- **Traceability check.** `CatalogueTraceabilityTest` is a plain JUnit test (it doesn't boot the app). It parses catalogues 01–04 from the classpath (not 05, which belongs to the Android harness) and fails if: + - a catalogue name has no declared `@Test` method in its mapped class (the Compat class for `S` names), or has more than one; + - an `IGNORE` name's method lacks `@Ignore`, or its reason differs from the row's (the cell text after `IGNORE: `, verbatim); + - a method's Needs-derived categories (method and class annotations) differ from those its row implies (`RELAY` → `NeedsRelay`, `NAMES` → `NeedsNames`, `FIXTURE` → `NeedsFixture`, `MCAST` → `Multicast`, `SLOW` → `Slow`); `Smoke` and `StableCompat` are not compared; + - a base with `S` names lacks `CompatStableTest` with `@Category(StableCompat::class)` overriding `peerVersion`; + - a `@Test` method in `chat.simplex.app.ui` has no catalogue row (except the traceability test, the key lint and its source-level check (9.4) and the harness's own tests in `chat.simplex.app.ui.harness`: VT model, longest link, reset completeness, alert dismissal by reflection, render cost; the harness tests carry no `Smoke` category); + - a committed screenshot under `uitest-screenshots/` belongs to no catalogue test (its `/` has no catalogue name); + - in strict mode (`-PuiStrict`, passed to the test JVM as `simplex.uitest.strict`, 7.1; P7), any non-`IGNORE` name still carries `@Ignore`. +- The check finds classes by walking the test classes directory and loads them with `Class.forName(name, false, loader)`, so no test class is initialised (initialising one would run `UiTest`'s per-JVM setup). +- **Assumption skips.** A test may skip itself through a JUnit assumption only where its row says so (one row: catalogue 02 `meta_signature_missingAlert`, which depends on the stable tag); the runner reports skips separately from passes. +- **Overlaps are tested once.** The owner of each overlapping feature is fixed in `00-legend.md`. +- **Badge display stays excluded.** No row asserts a badge; the supporter-perks screens are tested without badge display. +- **Categories.** `XFTP` needs no category (`xftp1` runs in every JVM). `StableCompat` marks the stable subclasses. `Smoke` marks one test per sub-area with runnable tests (71, P2), never an `MCAST` test; `CatalogueTraceabilityTest` also carries `@Category(Smoke::class)`, so `-PuiTags=Smoke -PuiExcludeTags=StableCompat` runs the 71 smoke tests plus the traceability check. + +### 10.2 Runtime and resources + +Per test: a reset (database, prefs, composition; a few seconds) plus the test itself (5–20 s typical; the 30-second timed-message test and the other `Slow` tests up to 10 min). The full suite is several hours serially. Classes run in parallel with `-PuiForks=N`; each JVM has its own data directory and its own server ports. Memory: the Gradle single-use daemon (~2 GB, `org.gradle.jvmargs`), plus per parallel JVM a 2 GB heap, the native core, `uitest-servers` and the peers (an idle CLI peer is about 60–80 MB RSS; the 21-member group test needs about 1.7 GB of peers). As built, the runner picks `-PuiForks` from free memory: (MemAvailable − 2.5 GB) / 1 GB, between 1 and 4, unless `-PuiForks=N` is given, and refuses to start with less than 3 GB available (15.4 item 24); pass a lower `-PuiForks` when a class with a large group runs. The `Smoke` run (71 sub-areas with runnable tests, plus the traceability check) boots 71 JVMs, each with a boot, server start, a reset and one test. With `forkEvery = 1` (the design; as built the default is 0, 0.5.9) Gradle also forks a JVM for every other class whose tests the categories filter out (verified with Gradle 8.12; such a JVM initialises no test class and exits quickly), so about 115 forks in all: an estimated 30 minutes serially and about 15 with 2 forks (P1 measures it). It is meant for checks before a PR, not for every edit; a single class runs with `--tests`. + +## 11. Infrastructure the catalogue needs + +### 11.1 Harness pieces (test code only) + +| Piece | Used by (Needs code) | How | +|---|---|---| +| Local SMP and XFTP servers | everything (`SMP`, `XFTP`) | (As built: `smp1` only; `xftp1` is configured in the GUI but not served, P1b, 0.5.6.) `uitest-servers` `smp1`/`xftp1` (8.2), once per JVM; peers get `-s`/`--xftp-server`, the GUI gets them in `resetForTest` (or `useLocalServers()` after onboarding) | +| Second SMP / XFTP server | private routing, sent-via-proxy, unknown-XFTP approval, manual server add, a member connection that fails (`SMP2`, `XFTP2`) | `uitest-servers` `smp2`/`xftp2` on host `127.0.0.1` | +| Names | SimpleX names (`NAMES`; 13 tests) | the process-lifetime resolver behind `smp1` and `register` lines; GUI and peers get the names role | +| Chat relays | channels and relay settings (`RELAY`; 90 tests) | headless relay processes (8.1) | +| Network namespace | every test | 8.3 | +| CLI peers | `P`, `S` | `CliPeer` (8.1): the current build from `dist-uitest`, or the release binary where a `P` row names a stable peer; for `S`, the release binary, in `…CompatStableTest` (10.1). Peers use `smp1`/`xftp1` unless a row puts them on `smp2`/`xftp2` (`PeerServers`) | +| Multicast | `MCAST` | the loopback multicast route (8.3); `Multicast` category, run serially in the second pass (7.1.1) | +| Developer tools | `DT` | `appPrefs.developerTools.set(true)` after `app()` (SimpleXAPI.kt:149); the toggle itself is tested through the UI in catalogue 04 | +| Local HTTP server | link previews (`HTTP`) | serves pages with Open Graph tags on `http://127.0.0.1:`; `privacyLinkPreviewsShowAlert` is pre-set except in the first-time-alert tests | +| SOCKS5 stub | SOCKS proxy routing (`SOCKS`) | in-JVM SOCKS5 server on a port from the JVM's block, with RFC 1929 username/password auth (the default proxy mode isolates by credentials, SimpleXAPI.kt:5301) and domain addressing; records CONNECTs to the local SMP port | +| Server control | delivery errors, retries, upload/download failures, address-switch abort (the switch stays abortable only while the SMP server is stopped) (`SRVCTL`) | `stop`/`start`/`reset` (8.2); races are avoided by stopping the server before the action (a 17 MB file leaves only about 0.4 s of visible upload and about 1 s of download, measured, so a large file alone doesn't avoid them); an XFTP request in flight when its server is reset hangs until its request timeout (TCP connection background timeout + protocol background timeout + 4096 KB × the per-KB timeout: `http2RequestTimeout` adds the connection timeout to the chunk timeout, simplexmq Transport/HTTP2/Client.hs:189-194, 209-210, FileTransfer/Client.hs:209-214, 300-306), and the retry 2 s later gets AUTH at once; the error arrives 75 s after the reset with CLI defaults (127 s with `--tcp-timeout 14`; 118 s with the GUI's 30 s background timeout and 10 ms/KB, SimpleXAPI.kt:5256-5258), so a test that resets an XFTP server during a transfer first sets short network timeouts and passes an explicit timeout, or is `Slow` with an explicit 180 s timeout (measured, matching that sum plus the 2 s retry — 57 s with the lowest values the settings UI offers, about 20 s with the UI's lowest per-KB value plus `/network socks=off timeout=1` in the chat console, which catalogue 02 `sndFile_xftpResetDuringUpload_showsFileError` uses); the next reset restores the baseline. After a plain stop/start of the XFTP server, by contrast, an in-flight upload may hang the same way (2 of 4 measured runs; the other 2 finished 0.8–1.7 s after the start) and then completes: the store log keeps the FNEW record, so the retried chunk is accepted (tests/ChatClient.hs:711-712, simplexmq FileTransfer/Server/StoreLog.hs:121-123, FileTransfer/Server.hs:572-605); while the server is still down the errors are temporary and never surface: each round of 3 attempts ends, after the backoff that follows the third failure, without an error, and the worker restarts at once (FileTransfer/Agent.hs:268-277, 520-544, Env/SQLite.hs:257, Agent/Client.hs:2311), taking the chunk with the fewest retries (each retry adds 1, AgentStore.hs:3777, 3824) from its stored delay, so the upload resumes once the server is back, though after a longer outage the next attempt can be up to 180 s away. After a restart, clients reconnect with backoff from 2 s up to 180 s (simplexmq `defaultReconnectInterval`, Agent/Env/SQLite.hs:197-202; message delivery retries cap at 120 s, :205-219; both stay at 2 s for 10 s, then grow ×1.5), which `UiIdle` doesn't see. `/reconnect` (`ReconnectAllServers`) does not shorten this: with the server down the agent holds no connection to close, and the resubscription and delivery workers keep sleeping (Agent/Client.hs:1048-1079 closes only existing clients; the resubscription worker, Client.hs:820-853, and delivery retries, Agent.hs:2601, keep sleeping; measured: 40 s down → delivery 14.9 s after the start, with or without `/reconnect`; 10 s down → 3.2 s; 20 s → 4.4 s). So tests keep outages under about 10 s (the backoff stays at 2 s for the first 10 s), or stop and start their CLI peers after the server starts (`PEERCTL`, delivery within about 2 s, measured 0.9 s unencrypted and 1.5 s with `--key`; the GUI side the same with `restart()`, about 1–3 s), or pass an explicit timeout of about half the outage plus 5 s | +| Peer control | ratchet sync, offline peers, stopped relays (`PEERCTL`) | `CliPeer.stop/start/snapshot/restore` (8.1), `RelayPeer.stop/start` (9.1) | +| Core restart inside a test | passphrase prompts, lock screen, startup DB errors, persistence, self-destruct, notices checked at first composition (`RESTART`) | `restart()` (7.3) | +| Boot without a user | onboarding (`ONBOARD`) | `app(name, onboarded = false)` (7.2); `ONBOARD` means the test boots with no user | +| Database fixtures | legacy states, startup DB screens, invalid-data items, backdated items (`FIXTURE`) | `app(name, fixture = …)` (7.2), or a fixture passed to `restart(fixture)` (applied while the core is stopped, 7.3) for data the test produced itself. **SQL edits** (backdating, fake migration rows, legacy rows) use the current CLI, as the system `sqlcipher` (3.x) can't open SQLCipher 4 databases: ` -d [--key ] --maintenance -y -e "/sql chat " -t 0`, with stdin `/dev/null` (with several users and none active the CLI would otherwise wait at "Select user profile:"; it re-activates a lone inactive user on open, so a fixture that clears `active_user` does that last), and the helper fails if the output contains `chat db error` (a failing `/sql` still exits 0) (or `/sql agent`; `--maintenance` doesn't start the chat; `-y` confirms pending migrations, which the default console mode would prompt for, Simplex/Chat.hs:77, e.g. on a v6.3.7 fixture). The database must already have a user: with none the CLI exits with "no active user in maintenance mode" (Hs/Core.hs:89-91), so SQL edits apply only to databases that have a user (the GUI's own after onboarding, or one created by a CLI). The key is `ksDatabasePassword.get()` for the GUI's own database and absent for a fixture database created unencrypted. **Databases from an older CLI** (`-PuiFixtureTags`, default `v6.3.7`, the last release before short links; downloaded by `uitestPrepare`; path from `UiEnv.fixtureCli(tag)`) are created without a pty and without `CliPeer` flags (v6.3.7 has no `--user-display-name`): `printf '\n' \| -d -s -e '' -t 1`, the display name answered on stdin; with no `--key` the database is unencrypted, which the GUI opens with the empty key (7.2). Fixtures always use the local servers | +| TTL expiry | chat TTL tests | setting or lowering a TTL through the UI expires backdated items at once (`APISetChatTTL`/`APISetChatItemTTL`, Library/Commands.hs:1842/1884); group items created in the last 12 h are kept (:5969-5970), so fixtures backdate both `item_ts` and `created_at` by more than 12 h. Only "expires on startup" waits for the expiration thread (first run 30 s after start, then every 30 min, src/Simplex/Chat.hs:124-127) and is `Slow` | +| Stubs | `FD`, `CB`, `URI`, `NTF`, `TOAST` | file dialogs (7.6), in-memory clipboards, recording `LocalUriHandler`, `RecordingNtfManager`, recorded toasts, AppLock focus simulation (`appWasHidden()` + `recheckAuthState()`), `escape()` | +| `platform` object | app-update notice | `UiTestPlatform`, the same `PlatformInterface` as desktop/Main.kt:65-90 | +| Pre-set prefs | every onboarded test not about them | `laNoticeShown`, `appUpdateNoticeShown`, `whatsNewVersion` (via `setLastVersionDefault`), `oneHandUICardShown`, `addressCreationCardShown`, `getStakeBannerDismissed`, `supporterBannerShown`, `showHiddenProfilesNotice`, `showMuteProfileAlert`, `showDeleteConversationNotice`, `showDeleteContactNotice`, `privacyDeliveryReceiptsSet`, `privacyLinkPreviewsShowAlert`. Tests about a notice clear its pref; notices checked once at first composition (SimpleX Lock notice, App.kt:94-103; app-update notice, desktop Main.kt:79-87; What's New, ChatListView.kt:219-228) need a `restart()` after their conditions are set up Separately, the per-JVM baseline (7.2 `bootJvm`, before `PrefStores.capture`) sets `AppearanceBarsBlurRadius` = 0, toolbar blur off, for every test including onboarding boots (user decision 2026-10-13, performance); `appearance_toolbarBlur` switches it on itself. | +| Fixture files | attachments | In the repo: `tests/fixtures/test.jpg`, `test.pdf`, `test_1MB.pdf`, `test.txt`; `images/files.gif` (animated GIF, 1.0 MB); `website/src/video/connect.mp4` (1.9 MB, over the 1023 KB auto-receive limit); `docs/themes/example.theme` (theme import). Added in P1 to `apps/multiplatform/common/src/desktopTest/resources/uitest-fixtures/`, generated once with ffmpeg (commands in a README beside them, `-fflags +bitexact`, so they are reproducible) and committed: `small.mp4` (a 2 s test pattern, under 1023 KB), `audio.webm` (audio only), `video.webm` (with a video track), `broken.mp4` (random bytes with an .mp4 name, undecodable). Written by the harness at run time: a 150 MB file of random bytes (the XFTP-reset-during-upload test needs a few seconds of visible upload; a 17 MB file gives about 0.4 s), a sparse file over 1 GB, and a JPEG over 510 KiB (received as an image that isn't auto-accepted) | + +### 11.2 App-side seams + +Besides keys and typed semantics (which include the `PlatformTextField` key parameter of 3.3 and making `keyForItem` internal) and the `UiIdle` busy tracker of section 6 (hooks in `ChatController.sendCmd`, `startReceiver`, `withBGApi`/`withLongRunningApi`/`withApi`; approved 2026-10-13 and implemented, 0.5.2), the catalogue needs three seams, which are approved, and the screenshots (7.7) need a fourth, the display clock (approved 2026-10-12). Each is an `internal var` (or constructor parameter) defaulting to the production implementation. `bootJvm` installs the fakes of the three catalogue seams for every test (the display clock is set per test by `uiTest(realTimes)`, 7.7) (the production implementations need VLC, audio devices or an AWT clipboard, and some paths reach them without asserting on them), and `clearUiState` resets their per-test state together with the app's globals around them (`AudioPlayer.currentlyPlaying`, D/platform/RecAndPlay.desktop.kt:95; `RecorderInterface.stopRecording`, C/platform/RecAndPlay.kt:10); the catalogue codes mark the rows that drive or assert on a seam. + +| Seam | Location | Unblocks | +|---|---|---| +| Recorder factory | D/platform/RecAndPlay.desktop.kt:25-90 (VLC `pulse://`/`dshow://`) | voice recording and voice drafts (9 tests; catalogue code `REC`) | +| Audio player / video frame provider | RecAndPlay.desktop.kt:92, D/platform/VideoPlayer.desktop.kt:217 (player), :221 (`getBitmapFromVideo`), :269 (`hasVideoTrack`) | voice playback, video previews and playback, the decoding-error alert, the video-or-file choice for pasted .webm files, the incoming-call ringtone (11 tests; code `AV`); the seam is an app-side interface (vlcj's `MediaPlayer.status()`/`media()` and their API classes are final and need native libvlc, so the fake can't be a vlcj object); the fake starts at position 0 and advances on the real clock (a fake that reports the end on the first poll raced the two assignments `play()` makes after `start()` returns, leaving the pause icon in 64 of 600 runs), and at the end of a track it reports a position equal to the media duration (`status().time()` == `media().info().duration()`, RecAndPlay.desktop.kt:269-273) while `isPlaying` is still true, as VLC does (:132-137), so the app's poll ends playback and sets progress to 0 for `resetOnEnd` (voice items, CIVoiceView.kt:66) or to the full duration (the compose preview, ComposeVoiceView.kt:56), :209-212, instead of leaving a partial slider | +| Display clock | `getTimestampText`, `getTimestampDateText`, `localTimestamp`, `localDate` (C/model/ChatModel.kt:3944-3998), `FloatingDate` (C/views/chat/ChatView.kt:2894), the 60 s grouping check (ChatView.kt:3765, :3784), the SimpleX-name sale countdown (SetSimplexNameView.kt:195-205): an `internal var` with `displayInstant(t)` and `displayNow()`, production = identity and `Clock.System.now()` | stable screenshots (7.7): in UI tests every displayed time and "now" map to one fixed instant (all tests unless `realTimes = true`) | +| Clipboard read on Ctrl+V | D/platform/PlatformTextField.desktop.kt:147-152 (AWT system clipboard, throws headless) | Ctrl+V in the compose field when the clipboard holds plain text or an image rather than a file list (1 test; code `IMGCB`) | + +OS drag and drop is not exercised: the drop target (`desktopOnExternalDrag`, D/platform/Modifier.desktop.kt:19-58, used at ChatView.kt:979-987) needs an AWT drop event, and the compose state it feeds is local to `ChatView` (ChatView.kt:148). The attach path behind it (`onFilesAttached`, ComposeView.kt:290) is reached by Ctrl+V of a file list from the in-memory clipboard (PlatformTextField.desktop.kt:144-145 → ComposeView.kt:1516). Message links are clicked through the text layout (3.5, no app change). + +### 11.3 Out of reach in-process (rows marked `IGNORE` with one of these reasons) + +- Real WebRTC calls (the browser page). +- Tray, close-to-tray, window size/position persistence, the floating console window, the crash-restart loop and crash alert. +- The app-update download (hard-coded GitHub URL and private HTTP client). +- OS folder opener (`desktopOpenDir`), until the seam in section 13 is approved. +- OS drag and drop onto the window (11.2). +- Destinations on public servers (e.g. "Follow SimpleX Network News", badge-code redemption, the migration upload, which goes to the preset XFTP servers of a temporary user created at `SimpleXAPI.kt:722`): unreachable in the namespace. +- Known app bugs that make the expected result unreachable, until fixed (each is reported when the test is written): the "Error importing chat database" alert (`apiImportArchive` throws `Exception`, `importArchive` catches only `Error`). +- Pixel-level visual settings: only the persisted prefs are asserted. +- Host "pending approval" and "join rejected", which need Haskell `chatHooks`. +- External resources: the channel-webpage embed script (loaded from simplex.chat); real Tor and onion services. +- Code that is commented out or unused, where a visible entry point remains in the source: "Sign messages", `migrationFromStage`, the expired group-invitation item (`CIGISExpired` is never set by the core); commented-out features with no remaining entry point ("Renegotiate encryption", invite by email) get no rows. + +## 12. Phases and PRs + +| Phase | Deliverable | Depends on | +|---|---|---| +| P1a (complete 2026-10-13, 15.1) | A thin vertical slice that proves the approach on the real app before the rest is built (about 1–2 weeks): the runner script and namespace (7.1.1, 8.3); `desktopUiTest`; per-JVM directories; `UiTestApp` (`bootJvm`, `resetForTest`, `restartCore`, onboarding boot); `UiIdle` (approved, 6, 13) with the hard timeout (no other watchdog parts); `InProcessDriver` with click, text input, keys and `waitUntil`; `CliPeer` Current only; `uitest-servers` with one SMP server only (no XFTP, relays, names resolver or control protocol); pre-set prefs; the keys and screen objects these tests use. Tests (catalogue names, Current CLI only, no stable subclasses): the three of 9.3 (`oneTimeLinkCliConnectsViaShortLink`, `addressCliConnectsAndRequestAppears`, `groupInvitation_tapRowShowsJoinAlert`), 02 `sendText_enter_peerReceives` and 04 `appearance_settingsPersistAcrossRestart` (a `restartCore` with `PrefStores`). Not in P1a: screenshots, Kover, the seams of 11.2, the dialog drain and file choosers, the Stable CLI, relays, mobile hosts, HTTP and SOCKS stubs, stub generation and traceability, K0. Exit: the 5 tests pass 20 consecutive runs; these are measured on the real app and recorded in 15: boot and reset time per test, frame render cost (14), `captureToImage` time on the full scene (the settle-step input of 7.7), memory growth over 20 resets in one JVM, and the offline Gradle run in the namespace; the scene runner choice (`runInternalSkikoComposeUiTest` or the fallback) and the DSL shape are confirmed or changed before P1b | — | +| P1b | The rest of the harness, completing what P1a started: runner script and namespace (7.1.1, 8.3), `desktopUiTest` and `uitestPrepare` tasks, per-JVM directories, `UiTestApp` (`bootJvm`, `resetForTest`, `restartCore`, onboarding boot, fixtures), `UiIdle` (approved 2026-10-13, 13) and the watchdog (6), the four seams of 11.2 and their fakes (and the fixed display clock), screenshots (7.7: capture, settle loop, masks, compare-and-write, summary, docs index, the `.gitattributes` LFS line; `uiVolatile()` on the volatile composables), coverage measurement (7.8: Kover on `desktopUiTest`, per-pass reports merged by the runner, the zero-coverage list), `InProcessDriver` with all input methods, `TestRoot` stubs, `CliPeer` (Current + Stable, peer control), relays and mobile hosts, `uitest-servers` (cabal flag, all servers, names resolver, control protocol), HTTP and SOCKS stubs, pre-set prefs; K0 (all helper parameters and fixed keys) and the keys and screen objects the three 9.3 tests use (chat list rows and requests, group invitation row, user picker and address screen, new chat sheet and one-time link, chat screen, compose and items); the catalogue and legend moved into the repo; `CatalogueTraceabilityTest`; every catalogue name generated as a stub in its mapped class (the Compat class and its stable subclass for `S` names) with the categories its Needs imply (10.1) — `@Ignore("not written")` for runnable rows, `@Ignore("")` for `IGNORE` rows (the generator escapes `"`, `\` and `$`); the three tests of 9.3; measurements of frame render cost (14) and of the `Smoke` run time (10.2); one run of the connection-failure recipe of catalogue 03 `memberInfo_connectionFailedSection`, which is derived from the core and agent code only, choosing its sync point before the server reset; on day one, `./gradlew -g .gradle/uitest-home --offline --no-daemon help` on the real project inside the namespace, after the outside step has filled that home (configuring the real project there has worked once, `./gradlew help` in 3 min 11 s; an offline run is not confirmed yet) | P1a | +| P2 | One stub per sub-area with runnable tests (71) turned into a passing `Smoke` test, with the keys each needs | P1b | +| P3 | Catalogue 01 in full (352 tests) | P2 | +| P4 | Catalogue 02 in full (281 tests) | P2 | +| P5 | Catalogue 03 in full (312 tests) | P2 | +| P6 | Catalogue 04 in full (232 tests) | P2 | +| P7 | Key lint (9.4); strict traceability: every name is a passing test or `@Ignore` with its row's `IGNORE` reason | P3–P6 | +| P8 | Changed-area runs (user decision 2026-10-13, later phase): `scripts/uitest.sh -PuiAffected=` runs only the tests a branch can affect. `git diff ` lists the changed files, and each file selects test classes two ways. (1) Catalogue refs: the rows whose code-ref cells cite the file give the test names, and through them their classes. (2) Coverage map: each full run records, from Kover (7.8), which test class executed which file; this also covers files no row cites. Changes to shared foundations (theme, `ChatModel`, `SimpleXAPI`, common helpers) add `Smoke`. Haskell core changes run `Smoke`, or everything on request. Unmapped files are reported and run `Smoke` | P7, 7.8 | + +All shared infrastructure is in P1, so P3–P6 are independent and can proceed in parallel. Within each, one PR per catalogue sub-area keeps reviews small (keys for that sub-area + its screen objects + its tests). + +P1a is the proof of concept: it runs real tests on the real app early, with the riskiest parts in place (boot, reset, restart, the scene runner, CLI peers, the driver), and its measurements adjust P1b before anything else is built. P1 (P1a and P1b together) fixes the API shape before keys are spread across about 190 files. Elsewhere in this plan "P1" means P1a and P1b together; measurements attributed to P1 are taken in P1a where its scope allows. The catalogue rows already contain the expected GUI state and CLI lines, so writing a test is mostly translating a row into DSL calls. + +## 13. Decisions log and open questions + +Settled (2026-10-10, with additions dated 2026-10-12): +- **Local servers:** `uitest-servers`, an executable component in `simplex-chat.cabal` behind a default-off `uitest` flag, using `tests/ChatClient.hs` (8.2). +- **Onboarding presets:** no core change; the run is network-isolated and tests switch to local servers after onboarding (8.3). +- **App seams (11.2):** approved — recorder factory, audio/video provider, Ctrl+V clipboard reader; and (2026-10-12) the display clock for stable screenshots (7.7). +- **Android (2026-10-12, changed 2026-10-13):** first out of scope, researched separately (`plans/ui-tests/2026-10-12-android-ui-test-automation.md`, Waydroid) with an Android-only catalogue, `05-android-only.md`. Since 2026-10-13 Android is in scope: its harness is built in the same worktree, and screens, helpers and test bodies are shared through `common/src/uiTestShared`, added to `desktopTest` and to `:android` `androidTest` (0.5.1, 0.5.4, 15.2–15.4). Android-only UI (catalogue 05) remains a separate effort. +- **Coverage (2026-10-12):** the run measures line and branch coverage of the UI, model and platform packages with Kover (7.8); zero-coverage files feed new catalogue rows or recorded exclusions; reported, not enforced. +- **Test tags only in test/debug builds (2026-10-12):** `uiTagsEnabled`, renamed `uiTestHooksEnabled` (2026-10-14), gates the tags and the `UiIdle` hooks (3.1, 0.5.2); release builds carry none. +- **Stable CLI:** downloaded by tag; default tag v7.0.3 (GitHub "Latest" on 2026-10-10). +- **Screenshots (2026-10-12, user request):** tests save committed PNG screenshots, rewritten only when the screen changes outside masked volatile regions or the set of volatile elements changes (7.7); purposes: regressions as image diffs, evidence that tests ran, documentation. Every passing catalogue test except `REALTIME` ones (and none with `-PuiShots=off`) saves an end-of-test shot plus named shots; PNGs are stored with Git LFS; displayed times come from a display-clock seam fixed in UI tests (11.2). +- **Implementation:** P1a approved (2026-10-13); it goes on a new branch off master in its own worktree, with one build per milestone. P1b is re-planned from P1a's measurements. +- **Decisions of 2026-10-13 (section 15 table, all approved):** the `UiIdle` hooks are approved (6); the OS folder-opener seam is deferred to P6; for Android, the restart is in-process (05 question 14), the debug-only network-security-config is approved (15), the `FIXTURE` proposal is approved (16), screenshots are deferred (17), and the toast hook is decided only if the `UiAutomation` check fails (13). +- **`UiIdle` hooks: approved 2026-10-13 and implemented** (0.5.2, 15.1). The fallback below is kept in case the hooks have to be removed. +- **Desktop forks (2026-10-14):** `forkEvery = 0` by default (one JVM per fork runs many classes; `-PuiForkEvery=1` restores one JVM per class), and the runner picks the number of parallel forks from free memory (15.4 item 24, 0.5.9). +- **Minimal app code (user rule, 2026-10-14):** keys go only on elements a test acts on or must read; no observation-only helpers (15.4 item 31). For now this supersedes typed semantics (4), "keys everywhere" (Summary item 1, 5) and the key lint (9.4). Revisit only with a new user decision. + +Open: +- **`UiIdle` fallback (kept for reference; the hooks are settled above).** The busy tracker adds a few lines to `sendCmd`, `startReceiver` and the `with*Api` launchers (and an `idle` parameter on `withLongRunningApi`); like the keys it is app-side code. **Approved 2026-10-13** (section 15). The fallback is kept documented in case the hooks have to be removed later; without them the harness works at a cost: waits use fixed deadlines (tests with long commands pass explicit timeouts), `drain()` becomes "`ctrlInitInProgress` false, then 1 s with no new chat event", the screenshot quiet wait becomes "1 s with no new chat event" and settled becomes "two equal captures with no chat event between them" (7.7), the end-of-test dialog poll becomes "a `DialogState` is pending, or 1 s with no new chat event" (6), and the `changingActiveUserMutex` check first waits up to 30 s for the lock (a user switch may be behind a slow core command), failure reports lose the in-flight list, and uncaught job errors are attributed by phase only, not to the test that launched the job (6). +- **OS folder-opener seam — deferred.** Opening a folder in the OS (`desktopOpenDir`, D/platform/Files.desktop.kt:37-48, reached from the database-folder buttons via `desktopOpenDatabaseDir`, :33, DatabaseView.kt:243, DatabaseErrorView.kt:330) can't be observed headless (it is also called from the app-update download, which is out of reach anyway). A fifth seam (an `internal var` folder opener) would make those rows testable. **Deferred to P6 (2026-10-13)**; until then those rows stay `IGNORE`. + +## 14. Risks + +- **Scale:** about 190 files of keys and 1,177 tests. Mitigated by keying the helpers first (K0, about 70% of sites), one PR per catalogue sub-area, and the traceability check showing what is left. +- **Suite duration:** hours when run serially. Mitigated by parallel JVMs (`-PuiForks`, bounded by RAM, 10.2), the `Smoke` category before PRs, and `--tests` for single classes. +- **Catalogue drift:** line numbers in the catalogue rot as the code changes; the test names and labels are what matter, and the traceability check only matches names. +- **Merge conflicts** with ongoing UI PRs while keys spread. Mitigated by sub-area-sized PRs merged quickly; keys are additive modifier edits. +- **Leaks per reset:** each reset leaves a Haskell controller with its agent threads, and old receiver threads can block up to 300 s; bounded by the number of tests one JVM runs. With `forkEvery = 0` (as built) that is a share of the whole run, so RSS (about 4.6 MB per reset, 15.1) and one receiver thread per reset accumulate per fork; `-PuiForkEvery=1` bounds them by one class. +- **Threading differences:** the default test dispatcher is `UnconfinedTestDispatcher` (ComposeUiTest.skiko.kt:116), so composition coroutines continue on whatever thread resumes them (often IO threads) rather than the EDT, which allows races the real app doesn't have; it also runs `snapshotFlow` collectors inline inside the measure pass, where they see a half-updated list state (measured on a copy of the console's two collectors, TerminalView.kt: the newest line stayed visible 1 time in 30, auto-scroll having turned itself off; ChatView has 7 similar collectors, ChatView.kt:2482-2955). P1 therefore uses `runInternalSkikoComposeUiTest(coroutineDispatcher = StandardTestDispatcher())` (:86-103; needs the `InternalTestApi` and `InternalComposeUiApi` opt-ins, both error-level, set module-wide in 7.1), which runs them during `advanceScheduler` on the EDT (measured: 30 of 30; continuations still queued when the scene closes run on the body thread inside `runTest`, ComposeUiTest.skiko.kt:201, and ones dispatched after that never run), unless it finds a reason not to, in which case the console rows (catalogue 04 `console_*`) read `chatModel.terminalItems` and scroll to the newest line; a failure that looks like such a race is checked against the real app before being reported as a bug. +- **Render cost:** with a manual clock every 16 ms of virtual time renders a full 1600 × 900 software frame on the EDT (ComposeUiTest.skiko.kt:206-221). If a frame costs more than 16 ms, virtual time lags real time (the advance cap, 6), so composition-scoped delays fire late. Measured in P1a: 2.6 ms per chat-list frame (15.1). As built, the poll loop advances a fixed 16 ms per iteration plus a 16 ms sleep, so virtual time runs at most about half of real time anyway (0.5.3). +- **One GUI per JVM.** Every other user in a test is a CLI peer; GUI ↔ GUI scenarios are not covered by this plan. +- **Early class loading.** If a test or JUnit extension initialises an app class before the base class sets `user.home`, the paths would point to the real home; `bootJvm()` checks the paths and fails fast. +- **Headless rendering** (Skiko software, no AWT window): layout code reads `simplexWindowState.windowState`, which the harness sets to the scene size; code reading real AWT windows (`LocalWindowWidth`, D/views/chat/item/CIVideoView.desktop.kt:37-44) falls back to it. + +## 15. Review status and open items (shipped 2026-10-13) + +**Review.** The plan and catalogue went through 49 rounds of adversarial review. Each round had five concurrent reviewers: +- A: consistency +- B: plan claims vs code +- C: catalogue rows vs code +- D: feasibility, measured on toy projects built on the real app jar and core +- E: Android catalogue and carry-over map + +Every finding was applied. The review then stopped on a time box rather than at "two clean rounds", because the two halves behave differently: +- **The harness design has converged.** B was clean in rounds 38–41, 46 and 48, D in rounds 37, 39, 40 and 48, and E in rounds 45, 46 and 49. The last design findings were details: the image-cache reset (7.3), the key for the security-code status line (4) and the dialog drain at the end of a test (6). All are fixed. +- **Catalogue rows have not converged.** Each fresh sample of 30–40 rows still finds 3–6 row-level issues. The recurring classes are: + - a confirmation alert missing from the steps (e.g. "Use current profile", "Connection request sent!", "Update network settings?"); + - an assertion on text that is only an icon's content description; + - a row that lists labels without a pass condition; + - a multi-test row that gives an outcome for only some of its tests. + + Expect issues at a similar rate in rows not re-checked since round 18. These are cheap to fix where they surface. Rule for P1 onwards: when a newly written test fails, check the row against the code before the app, and fix the row in the same PR. + +**Open items.** + +1. Decisions. **All recommendations below were approved by the user on 2026-10-13**; the "Recommendation" column is now the decision. + + | Decision | Recommendation | Why | Cost if declined | + |---|---|---|---| + | Go-ahead | Start P1a only (12) | 5 real tests on the real app validate boot, reset, restart, the scene runner and the CLI peers, and give the timings that every budget in this plan depends on; P1b is re-planned from those numbers | — | + | `UiIdle` hooks (6, 13) | Approve | A few lines in `sendCmd`, `startReceiver` and the `with*Api` launchers, counters only, with no behaviour change. Waits end as soon as the app is idle instead of at fixed deadlines, and failure reports name the job in flight | Fixed timeouts: slower tests, more flakiness, and errors attributed by phase only | + | OS folder-opener seam (13) | Defer to P6 | It only unblocks the few database-folder rows in 04; decide when P6 reaches them | Those rows stay `IGNORE` | + | Android question 14, restart | Defer until Android work starts; then prefer the in-process restart (stop the core, run the startup steps again, relaunch `MainActivity`) | It mirrors desktop `restartCore` and keeps each test in one `am instrument` run | — | + | Android question 15, debug-only network-security-config | Approve when Android starts | A debug source-set XML that allows cleartext only to `127.0.0.1`; release builds are unchanged | 3 link-preview tests fail, and 1 passes vacuously | + | Android question 16, `FIXTURE` mechanism | Approve the proposal when Android starts | It reuses the desktop SQL helper host-side; no app change | 38 carried-over tests stay unrunnable | + | Android question 17, screenshots | Defer | Android screenshots are a nice-to-have; failure PNGs already work | No committed Android shots | + | Android question 13, toast hook | Decide only if the `UiAutomation` check fails on the device | The hook is needed only if system toasts don't reach the listener | `TOAST` rows can't assert | + +2. Measurements. Measured in P1a on the real app: the render cost per 16 ms frame (2.6 ms, 15.1). Not measurable until P1b builds them (so far measured only on toy projects with the real jar): + - the settle-step time and per-shot budget (6, 7.7); + - the timing of the end-of-test dialog drain (6); + - the Kover overhead (7.8). +3. App bugs found while cataloguing, which are outside this plan and are reported when their tests are written (11.3): + - The "Error importing chat database" alert is never shown, because `apiImportArchive` throws `Exception` and `importArchive` catches only `Error`. + - The migration upload goes to preset public XFTP servers, so its rows are `IGNORE` in the network-isolated run. +4. Known nit, left as is: `previewMutex` (LinkPreviews.kt:46) is a `Mutex`, not one of the mutable types the 9.4 source check lists. It is harmless, because its fetches have bounded timeouts. +5. Review artefacts: the per-round findings are in the session scratchpad (`r{a..e}/findings.md`), and the resume state is in `plans/ui-tests/catalogue/verify/REVIEW-STATE.md`. Toy harness sources (real-jar experiments) are in `r46d`–`r48d`. + +### 15.1 P1a results so far (2026-10-13, first session) + +These were measured on the real app, built from master `eda94a505` on branch `nd/desktop-ui-tests` in `~/wt-uitest`, with no commits yet. Each run used a headless scene of 1600 × 900 with a `StandardTestDispatcher`, inside the namespace with the dedicated Gradle home, offline. The harness so far is `HarnessBootTest` and `HarnessMeasureTest` (P1a progress: `plans/ui-tests/catalogue/verify/P1a-progress.md`). + +- **Day-1 risk check passed.** The outside step filled `.gradle/uitest-home` (1.1 GB), and the offline `--no-daemon` run inside `unshare -rn --pid` compiled and ran the tests. One test costs 66–80 s of wall time, mostly Gradle configuration. +- **Boot works.** The JVM loaded `libapp-lib.so` and ran `initHS`, `initChatController`, `apiCreateActiveUser` and `startChat`, and `AppScreen()` showed the chat list ("Chats", "Private notes"). Boot, the first reset and the first frame took 10.7–11.5 s. +- **Reset time is the cost driver.** A reset took 7.2 s (median of 20; the slowest was 8.5 s), and 6–8 s of that is `initChatController` creating and migrating a fresh database. The other steps took 5–14 ms to stop and delete, 40–120 ms to create the user and 40–340 ms to start the chat. Over 1,177 tests that is about 2.3 h of serial reset time. **Fixed in P1a (same day):** a template database is migrated once per run and copied in before `initChatController` (7.2, `DbTemplate`). The JVM that creates it publishes it with an atomic rename, and parallel forks reuse it. A reset now takes **0.94 s** (median of 20; the slowest was 1.08 s), and the first reset of a later JVM 3.8 s instead of 11.5 s. The template is per run, so it always matches the current core's migrations. +- **Leaks per reset.** RSS grew from 412 to 504 MB over 20 resets (about 4.6 MB each), and live threads went from 15 to 35 (one per reset, the old receiver as 14 predicts), while the Java heap stayed at 21 MB. That is within the class-sized JVMs of 10.2. +- **Rendering is cheap.** One 16 ms frame of the chat list took 2.6 ms on average, and `captureToImage` of the full scene took 10–23 ms. The settle-step and per-shot budgets of 6 and 7.7 (about 1.4 s per step, 28 s per shot) are therefore conservative, and P1b can lower them after measuring a busy chat. +- **Confirmed as predicted:** the theme detector's thread throws a `NullPointerException` when the shutdown hook destroys `gsettings monitor` (8.2). No harness code suppresses it: `UiUncaught` and the shutting-down flag are P1b (0.5.11). Boot without the pre-set prefs of 11.1 opens the connect-onboarding view (`addressCreationCardShown`). Without `TestServers.configure` (8.2), What's New opens with "View updated conditions". With `uitest-servers` in place (below) the stopgap is gone: `TestServers.configure` disables the operators and refreshes `chatModel.conditions`. +- **Setup found:** a new worktree needs `git submodule update --init --recursive` (nanohttpd). `~/build/stage-libs.sh` refuses on current master, because master's `build-lib-linux.sh` no longer matches the reviewed hash. The libs were copied from the build slot instead (`cp -al`, with `libapp-lib.so` as a real copy, since `cmakeBuildAndCopy` writes it in place). + +- **First catalogue tests pass** (session 2): `oneTimeLinkCliConnectsViaShortLink`, `sendText_enter_peerReceives` and `groupInvitation_tapRowShowsJoinAlert` pass together in 178 s. They use the first keys in app code: `Ui.kt`, alert confirm and dismiss keyed by manager, chat-list rows on both platforms, the new-chat buttons, `SectionItemView`'s `uiKey` parameter, `LinkTextView` and the message field. Four driver rules were found and are now implemented: + 1. A click waits until the node's bounds are unchanged between two polls. A click on a sheet still sliding in landed behind it. + 2. `typeInto` waits until the field's `EditableText` holds the text before the next action. Text input settles a few frames late, and an Enter before that sends nothing. + 3. Received CLI messages carry an `HH:MM ` prefix, which `expectsMessage` strips (8.1). + 4. A key on the text field itself carries `EditableText` on the same node, while `LinkTextView`'s key is on its clickable parent, so field reads match the node or its descendants. + +- **All five P1a tests pass individually** (session 2): plus `addressCliConnectsAndRequestAppears` and `appearance_settingsPersistAcrossRestart`. The second uses `restartCore` as 7.3 specifies, and its theme, font scale and zoom are checked both in `settings.properties` and in the UI after the restart. More keys were added: the user picker and its address and settings entries, `SettingsActionItem`'s and `ExposedDropDownSetting`'s `uiKey` (with options), the address create and share buttons, the appearance sliders, and the color-mode dropdown. Findings: + 1. **`TestRoot`'s in-memory clipboard is required, not optional.** The address is read from "Share address", which goes to `ClipboardManager.shareText`; with no AWT clipboard in a headless JVM, the stub is the only way to read it. + 2. **Some keyed clickables don't merge.** `ExposedDropdownMenuBox`'s clickable node does not merge its children, so the selected value is a child `Text` even in the merged tree. `textOf(key)` reads the node's own text, then its first descendant's, after waiting for the node. + 3. **Sliders work through `SetProgress`.** The semantics action also runs `onValueChangeFinished`, so the value is stored. + 4. **The restart logs harmless errors.** It logs `Error updating users` / `Error updating remote hosts: Controller is not initialized` from composables that run before init completes. The test passes; P1b decides whether `restartCore` should compose only after init has started. +- **Exit check, first attempt:** 12 of 12 consecutive runs passed (all five tests, 149–210 s per run). It was stopped there to continue work, and will be rerun on the final P1a code. +- **`UiIdle` hooks are in** (approved, 6): + - `sendCmd`: inside `withContext(Dispatchers.IO)`, with the status polls excluded by command type (since 2026-10-14 a thin `UiIdle.track` wrapper around that block, 0.5.2); + - receiver events: also counted, with the time of the last one (counters removed 2026-10-14; only the time of the last event is kept); + - `withApi`/`withBGApi`/`withLongRunningApi`: `idle = false` at `PopChatCollector`, `ProcessedErrors` and `createUpdateJob`. + + The harness uses them in `drain()`, the deadline extension and timeout messages. All 8 tests pass with them. +- **Peer start is asynchronous, as 8.1 intends, with a 60 s deadline.** A new peer's first start (the `-O0` current CLI) took 8.1–9.6 s at a load average of 9–17, and more than 30 s right after a 13-minute Kotlin compile (load average about 20), which failed a test with the synchronous 30 s start. `cli()` now returns at once, and the peer's first command waits for `Current user:` and the prompt. + + +- **The full reset of 7.2–7.3 is in:** + - `PrefStores`: raw `Properties`, the files and `themes.yaml` restored; the `themeOverridesStore` cache nulled; every `SharedPreference._state` refreshed by reflection; theme and locale re-applied; the four model fields copied from prefs once refreshed. + - `ModelSnapshot`: a reflection walk of `ChatModel`, its contexts, `AppLock`, `ConnectProgressManager`, `ChannelRelaysModel`, `BadgeModel` and `VideoPlayerHolder`, excluding `ChatController` and `AppPreferences`. + - `clearUiState`: image and wallpaper caches, `cachedRemoteFileRequests`, the file-level list states, window state and auth state. + - `clearTransientUi`: modals, passcode views, pending dialogs, both alert managers and toasts. + + The template database's key is installed after `PrefStores.restore`, which would otherwise wipe it. `HarnessResetTest` (two tests in one JVM) shows that a preference, an open sheet and a contact from the first test are gone in the second. All 10 tests pass in one run; the median reset is 1.67 s. + +- **Two run-time cuts (user request, 2026-10-13):** + 1. **The runner skips the outside Gradle step** when a fingerprint is unchanged. The fingerprint covers the build files (`*.gradle.kts`, `gradle.properties`, version catalogs), the native C sources, and the staged native libs other than `libapp-lib.so`, which the step itself writes. `env.properties` is now written by the script on every run, and `uitestPrepare` only resolves `desktopTestRuntimeClasspath`. The step took 40–150 s per run; the inside run still compiles changed Kotlin, offline. + 2. **A peer database template.** While the outside step runs, the runner migrates one user-less CLI database per run: `--maintenance` exits with "no active user in maintenance mode" after running all 174 migrations. Each new peer's directory gets a copy, and the peer creates its user on first start. Measured, interleaved at a load average of 28–31: a first start with a fresh database took 11.4–13.6 s; from the template, 0.36–0.42 s. This replaces 8.1's "4–15 s per new peer". + +- **P1a exit check passed (2026-10-13):** 20 of 20 consecutive runs of the five tests on the final P1a code (all passed, 98–329 s per run, mean 142 s; the slow early runs were at a load average of about 30 from other sessions, the later ones 98–127 s). Earlier loops on intermediate code: 12/12 and 7/7. **P1a is complete.** + +Next: re-plan P1b from these results. + +### 15.2 First test batch after P1a (2026-10-13, user request: "the most important parts of the app") + +**Desktop: 12 more catalogue tests pass**, 17 in total including P1a: + +| Area | Tests | +|---|---| +| Messaging | `unreadCounterIncrementsAndClears`, `reply_menu_sendQuoted_peerSeesQuote`, `delete_ownForEveryone_peerSeesMarkedDeleted`, `reaction_add_peerSeesAdded`, `reaction_received_chipCount` | +| Contacts | `contactRequestMenuAccept`, `contactRequestMenuReject`, `contactInfoSetLocalAlias` | +| Profile | `editProfileDisplayName` | +| Groups | `createGroup_opensChatAndInviteSheet`, `groupSend_allMembersReceive`, `leaveGroup_fromChatListMenu` | + +**Two are parked with `@Ignore` and a reason** (both unparked in batch 2, P1a progress note; no test is `@Ignore`d now except the `HarnessMeasureTest` benchmark): +- `edit_menu_updatesPeer`: a test can't replace the pre-filled text of the controlled message field in edit mode. Neither `performTextReplacement` nor select-all plus `performTextInput` reaches the compose state. +- `deleteContactAndNotify`: "Delete contact" is an unplaced lazy-list item on the info screen, with zero bounds, and a bounded `ScrollBy` did not bring it into view. + +**New keys:** +- chat items, their context-menu actions (`ItemAction` gained a `uiKey` parameter, on 5 overloads), reaction buttons and chips; +- the send button; +- chat-list row menus (accept, accept incognito, reject, leave); +- `DefaultAppBar`'s title (a `uiKey` parameter); the info screen's alias field and delete button; the delete dialogs' options (`Ui.Alert.Option`); +- the user picker's current user (desktop); +- `ProfileNameField` (a `uiKey` parameter); the group name and create button; the profile save button. + +**Driver rules found and implemented:** +1. **Message commands have no echo.** Their CLI "echo" is the sent item `HH:MM @alice …`, so `sends` accepts either form (8.1). +2. **The verified Enter.** After a context-menu action, the first Enter is dropped (cause still open), so `send` waits for the send button to be enabled, presses Enter, and presses once more if the field hasn't cleared within 1.5 s (now 5 s, and only while the field still holds text and Send is enabled: 15.4 item 10b). This is safe, because `inProgress` blocks a second send (`ComposeView.kt:631`). +3. **`escape()`** invokes the window back stack (`simplexWindowState.backstack`, filled by the desktop `BackHandler`), for example to close the address screen before using the chat list behind it. +4. **Scroll into view, bounded.** `performScrollTo` looped until the heap ran out (`OutOfMemoryError` with a 2 GB heap) on a node in the end pane. The driver now scrolls the nearest `ScrollBy` ancestor by the overflow (or a page for an unplaced lazy item), at most 10 times. +5. **Connections get 30 s.** Under load, a connection took more than 10 s, so the waits for "contact is connected" are 30 s (now 60 s, `CONNECT_TIMEOUT_MS`, 0.5.7). +6. **A per-test hard timeout** of 4 min (thread dump, then halt), plus heap dumps on OOM. Before them, one hang cost 35 minutes. +7. **Group sends wait for members to connect.** A group message sent before a member is directly connected arrives forwarded by the host, with a ` [>>]` suffix. The test waits for `#team: member alice is connected` on each member before sending (catalogue 02:40 note). + +**Full desktop run at the end of the day:** 24 tests (17 catalogue + 5 harness + 2 parked). After the group-send fix, everything passes except the 2 parked tests; the Groups class was rerun separately with 3/3 passing. + +**Catalogue facts confirmed by the real CLI:** +- a reaction to the peer's own message prints `alice> >> hi` (catalogue 02:177 fixed); +- leaving prints `#team: alice left the group (signed)`, as 03:240 already says. + +**Android (started on the same day):** +- `~/build/android.sh` gained `ANDROID_TEST=1`, which builds and publishes the `androidTest` APK next to the app APK. +- `android/build.gradle.kts` got the AndroidX runner, Compose `ui-test-junit4` 1.8.2, espresso-intents and uiautomator. +- `initUiTags(context)` switches tags on when the app is debuggable. +- `SmokeTest` boots, creates a user, launches `MainActivity` and finds the new-chat key. +- `scripts/uitest-android.sh` claims a Waydroid instance (default 2), installs both APKs, runs `pm clear` and grants permissions, runs `am instrument -r`, saves logcat and releases the claim. +- Research and the full step list: `plans/ui-tests/catalogue/verify/android-harness-research.md`. +- **The first Android instrumented test passes** (2026-10-13, Waydroid instance 2, claimed and released by the runner): `SmokeTest` reports `OK (1 test)`. The app's own startup in the instrumentation process took 15.4 s after `pm clear` (the estimate was ~45 s). The real `MainActivity` then showed the chat list, and the debug-only key `chatList.newChat` was found 18.2 s after start. The build takes 5 min 44 s for both APKs, from a slot whose Kotlin was already compiled. (The runner's `OK` check was later replaced: it parses `am instrument -r` into per-test `results.txt`, 0.5.9.) +- **Remaining for Android P1a** (about 2 days, from the research step list; status 2026-10-14 in brackets): + 1. the boot step plus reset and template (`AndroidUiTestApp`, `AndroidPrefStores`, `AndroidUiState`) [done as `UiTestApp`, `PrefStores` and the shared `CommonUiState`]; + 2. the shared `uiTestShared` source set (screens and test bodies over a `UiDriver`/`Peer` interface), with the desktop re-run [done, without the interfaces: each platform has a `UiScope` with the same methods, and peers share `PeerLines`]; + 3. a host-side peer bridge reached through `adb reverse`, with the SMP server outside the network namespace [done]; + 4. porting the 5 P1a tests [done]; + 5. the Android-only Back and notification tests [Back done, `BackKeyTest` 14; notification tests open]; + 6. a 10-run exit check [open]. +- Decisions: a shared `uiTestShared` source set for screens and test bodies, and the `~/build` test mode. + +### 15.3 Second batch (2026-10-14) + +**Desktop.** +- Shared tests now live in `common/src/uiTestShared`: screens, helpers, server config, `CommonReset` and the test bodies. +- New this batch: `ChatListMenuTest` (mark read/unread, favorite, mute, clear chat from the row menu) and `OneTimeLinkTest.oneTimeLinkSwitchToIncognito`. All 5 pass. +- The earlier full run passed 24/24 (19 catalogue + 5 harness). With the new tests that makes 24 catalogue tests in all; the whole suite has not been run again since. +- New keys: + - row-menu `markRead`, `markUnread`, `favorite`/`unfavorite`, `ntfs.` and `clear`; + - the one-time link's "Share profile" row (`link.shareProfile`) and its picker options (`link.profile.incognito`, `link.profile()`). +- New driver calls: + - `descriptionsOf(key)` returns content descriptions; for example, the row star is "Favorite" and the muted icon is "Notifications". + - `click` taps a point no nested button covers (15.4 item 8). The incognito option's centre is on its ⓘ button. + - `CliPeer.eventuallyMatching(regex)`. +- Peer timeouts now show the peer's output at the moment of timeout. Before, they showed the output at the start of the wait, which wrongly made a failed connect look like "the peer saw nothing". + +**Android.** +- Host bridge, shared tests over `adb reverse`, Espresso-Intents for share and copy, and an app APK install that is skipped when unchanged. +- Fixes this batch: + 1. `poll` advances the Compose clock (48 ms per step), as desktop does. Effect `delay()`s run on the test clock, so mark-read (600 ms) and the alias save (500 ms) never finished. + 2. The message field retries Espresso `replaceText` when the field is recreated while the chat opens. + 3. Accepting a contact request waits the 30 s connect timeout. + 4. A timeout takes a screenshot, which the runner pulls into `/failures/`. + 5. The runner stops the bridge JVM by its PID (`$!` of `setsid java …`). Gradle's forked daemon is in its own session, so stopping the process group left the bridge and servers running. + 6. The runner stops at once if the bridge exits. +- **Last run** (`run-20261014-090056`): 14 of 14 executed tests passed, including 3 of the 6 previous failures (unread badge, edit, group send). + - Claude Code stopped the run while the host was critically low on memory: 0.7 GB free, with about 7 GB held by another session's JVMs. + - Not reached: `ContactsTest` (5, including the remaining previous failures: alias, delete contact, accept request), 3 of the 4 `ChatListMenuTest` tests and the 2 one-time-link tests. + +**Status 2026-10-14, about 20:05 UTC (code before the PR; counts from the code, 0.5.1):** +- Desktop: 142 `@Test` methods (112 shared + 25 desktop-only + 5 harness; `HarnessMeasureTest` is `@Ignore`d, so 141 run). +- Android: 127 (112 shared + 14 `BackKeyTest` + `SmokeTest`). The last Android run (`run-20261014-164429`, 53/53) predates the exhaustive batches, so 74 shared tests had not run on Android before the final runs. +- **Latest run results (2026-10-14, final code):** desktop `b22-desktop.log`: 142 completed, 141 passed, 0 failed, 1 skipped (`HarnessMeasureTest`, manual), 6 min 37 s with automatic forks; Android full run `run-20261014-214038` on Waydroid instance 2: 127 tests, 126 passed, 0 failed, 1 skipped (`connectBannerCardOpensPasteLink`, no camera), 18.5 min. Open flakes seen in earlier runs of the same day: 15.4.1, 15.4.1a, item 41. + +**Earlier status, 2026-10-14 about 17:00:** desktop 43/43, twice in a row (`b13-desktop-1/2.log`); Android 53/53 in one run (`run-20261014-164429`: 38 shared + 14 Back-key + smoke, 16.5 min of instrumentation). The exhaustive pass then added 99 catalogue tests (74 shared, 25 desktop-only) in two batches (P1a progress, batches 5 and 6). + +**Earlier status, end of 2026-10-14 afternoon:** +- **Desktop 29/29** (24 catalogue + 5 harness): `~/uitest-review-tmp/b6-desktop-full.log`, 7 min 23 s including the build. +- **Android 25/25** (24 catalogue + smoke): `run-20261014-134126`, 39 s setup plus 5.3 min of instrumentation, with no crashes and no resumes. + +**Open.** +- ~~Rerun those 10 Android tests when memory allows~~ (done: all pass). +- ~~Delete-contact failure~~ (fixed, 15.4 item 6). +- ~~Android `PrefStores` reset~~ (done: `android/.../harness/PrefStores.kt`, used by `resetForTest`); ~~Android Back tests~~ (done: `BackKeyTest`, 14). +- Android notification tests; an Android 10-run exit check. +- Android template decryption: `bootOnce` now calls `apiStopChat` before `apiStorageEncryption` (15.4 item 25); verified on Waydroid in the final run (reset median 585 ms, n = 126). +- `wd.sh net isolate` in the Android runner (0.5.9); `xftp1` served by `uitest-servers` (0.5.6); peer `--mute` (8.1). +- The test-APK build spends about 80 s on the `~/build` snapshot and slot plus a cold Gradle daemon, before about 60 s of real work. A warm daemon during runs would save most of that, but costs 1.5–2.5 GB of RAM; not done. +- Nothing is committed. + +### 15.4 Edge-case failures found while running (2026-10-13/14) + +Each entry gives the symptom, the cause (how it was confirmed), and the fix or mitigation. These are traps any later test or harness change can hit again. + +**Android harness (instrumented, Waydroid)** +1. **Effect `delay()` never finishes.** + - Symptom: the unread badge stays after opening the chat; a local alias never saves. + - Cause: under `createEmptyComposeRule`, `LaunchedEffect` coroutines run on the test clock. Mark-read waits 600 ms (`ChatView.kt` `MarkItemsReadAfterDelay`) and the alias save debounces for 500 ms (`ChatInfoView.kt` `LocalAliasEditor`). Neither finishes unless the clock advances, and `waitForIdle()` doesn't advance it for a pending `delay`. + - Fix: every `poll` iteration advances `mainClock` by 48 ms, as desktop already advances 16 ms. Both tests passed after the change. +2. **Message field emptied after typing.** + - Symptom: "message field to hold `hello`" times out right after opening a chat (edit, group send). + - Cause (inferred): the compose `EditText` is recreated while the chat finishes opening, so text set by Espresso `replaceText` lands in the old view. + - Fix: `setMessageField` retries `replaceText` every 2 s for up to 10 s. Both tests passed after the change. +3. **45 s stall at the end of every test.** + - Symptom: tests took about 45 s longer than their steps. + - Cause: `ActivityScenario.close()` starts `InstrumentationActivityInvoker$EmptyActivity` from the test package to push the app to the background. The Espresso-Intents stub `not(isInternal())` treats the test package as external, so logcat shows "Stubbing intent … EmptyActivity", and `close()` waits out its 45 s timeout. + - Fix: `Intents.release()` before `scenario.close()`. Close now takes 0.4 s. +4. **The Haskell core crashes or hangs in database init** (arm64 core under libndk translation). + - Two failures, both in `chatMigrateInit` while re-creating a database between tests: + - (a) SIGSEGV in a `ghc_worker` thread. One register held an SQL fragment ("ate TEXT"), so it happened during migrations. + - (b) A hang at 0% CPU for 29 min. The thread dump (via `wd.sh rootsh kill -3`) shows `Instr` blocked in native `CoreKt.chatMigrateInit`, with no Kotlin lock involved. + - Not reproducible on demand, and not seen on desktop. Mitigations: + - (i) The template database is saved from the app's own startup database, so later resets copy it and run no migrations. The reset went from 31.6 s to about 4 s. + - (ii) A 5 min per-test watchdog logs all stacks and kills the process. + - (iii) The runner resumes with the tests not yet run (`-e notClass`, at most 4 passes) and records the test that was running as `crashed`. +5. **Clicking the middle of a row hits a nested button.** + - Symptom: "Incognito" in the profile picker opened the incognito info screen instead. + - Cause: the row's centre falls on its ⓘ button. + - Fix: `click` finds the nested buttons and taps a point they don't cover (item 8). A first fix, `clickStart` at 5% of the width, was removed: tests must not encode positions. +6. **A bottom app bar covers scrolled content** (Android delete contact). + - Cause, from the click log: the tap went to `(20,1381)–(700,1440)` in a 1440 px window, under the info screen's bottom app bar. One-hand UI is the Android default and puts bars at the bottom. `performScrollTo` stops as soon as the node is inside the scroll viewport, and the viewport extends behind the bar. + - Fix: after scrolling, `click` centres the node in its nearest `ScrollBy` ancestor, so bars at either edge can't cover it. The test passes after the change. +7. **Nodes with empty bounds counted as stable.** + - Symptom: after a restart on Android, `userPicker.settings` was clicked at `Rect(0,0,0,0)`, and on desktop the off-screen font slider was "stable" with zero size. + - Fix: `waitStable` requires non-empty bounds. Every action that needs a visible node scrolls it into view first; `setSlider` now scrolls too. Without that, a semantics-only action can hide the fact that a user couldn't reach the control. +8. **Rows with nested buttons** (generalises item 5). There are no tap coordinates or fractions in tests. + - `click` takes the target's unmerged semantics node and collects its clickable descendants (nodes with `OnClick`, for example the ⓘ button in the profile picker's "Incognito" row). + - It taps the centre when no descendant covers it. Otherwise it taps the middle of the widest span of the centre line that no descendant covers (`uiTestShared/.../harness/TapPoint.kt`). + - `clickStart` was removed. The incognito test passes on desktop with a plain `click`. +9. **Failure screenshots silently missing on Android.** The test package has no external files directory, so the screenshot errors were swallowed. Screenshots now go to the app's external files directory, and the runner pulls them after each pass, before `pm clear`. Screenshot errors are logged. +10. **A second activity in one test froze after its first frame** (Android `restart()`). + - Symptom: after `restart()` (close, `restartCore`, launch again), the chat list draws but taps change nothing. The profile button's tap sets the picker state, yet the picker never appears. + - Probes (throwaway, since deleted) isolated it: + - dark theme, then picker: passes; + - font scale, then picker: passes; + - relaunch the activity only: passes; + - `restartCore` only, activity kept: passes; + - close the activity, then `restartCore` with no activity, then launch while the core is still starting: fails. + - Fix: `restart()` restarts the core first, waits until chat is running with the user loaded, then closes and relaunches the activity. Restart semantics are unchanged: both the core and the UI are rebuilt, and settings are re-read. `AppearanceTest` passes on Android. + - The root cause inside the app or the test framework, where the UI comes up during core init with no prior activity, is not investigated. It may matter for a real cold start with a slow core and is worth a look if users report a frozen first screen. + +**Runner and host** + +10a. **The template depended on test order.** + - Symptom: a full-package run failed 26 of 27 tests with `apiCreateActiveUser(alice) failed`. + - Cause: `SmokeTest` ran first, created "alice" on its own path, and `bootOnce` then saved the template from that database. + - Fix: + - `bootOnce` asks the core (`listUsers`) and builds a fresh database before saving when users exist; + - `SmokeTest` now runs on the harness (`uiTest { app("alice") }`). + - Rule: no test may bypass `UiTest`/`app()` to set up state. + +10b. **The verified Enter double-pressed a slow send.** + - Symptom: the Android edit test failed intermittently, waiting for `chat.send` after the edit was already sent: the screenshot shows the edited message and an empty field. + - Cause: the retry for the desktop dropped-Enter quirk pressed again when the field hadn't cleared within 1.5 s. Under translation the send itself took longer, so by the second press the Send button had been replaced by the mic button. + - Fix: wait up to 5 s; re-press only while the field still holds text and Send is enabled; tolerate the button vanishing. The outcome check stays that the field has cleared. + +11. **The bridge outlived the run.** + - Cause: Gradle `--no-daemon` forks a single-use daemon in its own session (`setsid`), so killing the wrapper's process group missed the bridge's `JavaExec` child. + - Fix: the bridge is now started directly with `java -cp` and killed by its PID. +12. **App-code edits cost a 4–7 min desktop compile before any Android run.** + - Cause: Gradle `uitestBridge` depended on `desktopTestClasses`. + - Fix: `uitestBridgeClasspath` snapshots the harness classes once. The runner runs `java -cp` and calls Gradle only when a bridge source file is newer than the snapshot. The bridge is now up in 1 s. + - Rule: never edit `commonMain` while a bridge compile is running; its tests then compile against stale main classes ("Unresolved reference"). +13. **`adb root` on Waydroid restarts `adbd`**, which ends a running `am instrument` (exit 255). Use `wd.sh rootsh ` for root actions such as `kill -3 ` for a thread dump. `run-as` is blocked on this image. +14. **Misleading peer timeout output.** "seen: …" was evaluated when the wait started, so a failed connect showed `[]`. Peer timeouts now append the output at timeout time. +15. **Host memory pressure.** + - Other sessions on the qube took about 7 GB mid-run, and Claude Code stopped the background run with 0.7 GB free. The runner's 4.5 GB check at start doesn't protect a 30+ min run. + - Keep runs short (class filters), and stop Waydroid when idle. + - Never `pkill -f` a pattern that matches your own shell command line. +15a. **`pm clear` on a shared instance.** The runner runs `pm clear chat.simplex.app.debug` at the start of every pass. On instance 1, which other sessions use with the same debug package, that wipes their app state even when the claim is free. UI test runs use instance 2 only; the queueing helper waits for instance 2 and never falls back to 1. + +16. **Waydroid ran alongside a bridge compile.** The bridge compile now runs before the Waydroid boot, and the boot overlaps only the 1 s bridge start. Waydroid comes up 30–34 s after the run starts. + +**Desktop harness** (already in 15.2, listed here for completeness): the first Enter after a context-menu action is dropped (verified Enter); `performScrollTo` looped until OOM in the end pane (bounded `ScrollBy`); select-all must settle before `performTextInput` replaces text; connections need 30 s under load; group sends wait for each member's "member alice is connected". + +17. **Popups swallow the next pointer or key event.** A desktop popup that is still closing after a menu-item click takes the next right-click or Enter as an outside click and only dismisses itself. This is the likely cause of the old "first Enter after a context-menu action is dropped" quirk. + - Fix: `click`, `rightClick` and `pressEnter` wait until no popup is open, unless the target is itself inside one. +18. **The first tap after typing is lost** (desktop). Focus moves from the text field to the tapped control, and the focus change restarts the control's pointer handler mid-press. "Create profile" needed two taps. + - Fix: if the target supports `RequestFocus` and is not inside a popup, the harness focuses it through semantics, settles 3 frames, then taps. +19. **Controls clicked while still disabled.** "Create profile" stays disabled until the typed name validates. + - Fix: `click` waits until the target is enabled, with a clear failure message if it never is. +20. **`performScrollToNode` deadlocks on desktop.** It runs the scroll on the UI thread and waits for animation frames, but the desktop harness runs with a paused clock. One test JVM hit the 240 s hard timeout, and the halt lost that JVM's remaining tests. + - Fix: desktop `scrollTo(key)` pages lazy lists with `ScrollBy`, advancing 40 frames per page, until the key appears. It is polled, so rows that load late, such as group members, are found too. + - Android keeps `performScrollToNode`, because its clock auto-advances. + - Rule: never call `performScrollTo*` on desktop. +21. **The chat list lags the model after a settings change, and the model can be reverted by queued group events.** + - `rowMenu` waits for no API in flight and settles about 0.5 s of app clock before opening the menu. + - Group helpers call `quiesce()` (no API in flight and no events for 1 s) after joins. + - See 15.5 item 1 for the app-side race. +22. **Esc pressed before an async screen close finished.** Creating a profile is a chain of database calls (create, list users, reload chats) ending in `close()`. The test pressed Esc on seeing the new active user, which was too early. + - Fix: `ProfilesScreen.add` waits for the new-profile screen to disappear, not just for the active user to change. The general lesson: wait for the visible end state of an operation, not an intermediate model change. +23. **Parallel desktop JVMs raced on the shared DB template.** The losing atomic directory rename throws a plain `FileSystemException` ("Directory not empty"), not `DirectoryNotEmptyException`. + - Fix: if the template exists afterwards, the error is ignored. +24. **Speed (desktop).** `forkEvery = 0` (one JVM per fork runs many classes) plus parallel forks chosen from free memory (1 GB per fork, 2.5 GB reserved, at most 4). The full suite went from 7 min 23 s (serial, one JVM per class) to about 4.5 min for 43 tests. +25. **Android template encryption.** Decrypting the template with `apiStorageEncryption` while the startup chat controller held the agent database failed with "database is locked". Chat is now stopped first with `apiStopChat` before `apiStorageEncryption`. In the last run (16:46, before that change) decryption still failed with "database is locked", so resets opened an encrypted template (`reset.init` 3.5–5.2 s); verified in the final Android run (2026-10-14 21:40): no "database is locked", and `reset.init` dropped to about 50 ms. + +26. **The reset missed `by lazy` model state.** `ChatModel.migrationState` is declared `by lazy`, so the reflective `ModelSnapshot` saw only the `Lazy` wrapper. After the "migrate from another device" test, the onboarding screens refused to advance in every later test of the JVM, and 12 onboarding tests failed. + - Fix: `ModelSnapshot` unwraps `Lazy` fields (initialising them) and snapshots their contents. This applies to every `by lazy` model field on both platforms. +27. **App background errors were attributed to the wrong test.** A job started by the test's own actions (or by its UI teardown) fails after `runTest` returns, and coroutines-test reports it as "uncaught exceptions before the test started" in the NEXT test. + - Fix: each test body ends with `drain()` inside the test, so the test's own background jobs finish while it is still current. The desktop `finally` also flushes the Swing queue and drains, because scene disposal is queued on the EDT. + - The full exception list (`suppressed`) is now printed; Gradle's short format hid it. +28. **Known app issues are allowlisted by message, not ignored wholesale.** `KNOWN_APP_ISSUES` lists app errors traced to a documented finding (15.5 item 3); a match is logged as "UITEST known app issue, not failing the test". It passes a test only when its body completed; a test whose body never ran because of such an error is rerun (at most 3 attempts), and one whose body started but did not complete fails. `AssertionError`s never match. Any other uncaught exception still fails the test. Desktop only; details in 0.5.8. +29. **Desktop failure screenshots.** A desktop `poll` timeout writes `/failures/-NN-.png` (`captureToImage` of the window root). The first one showed that the "chat list title" expectation was wrong (15.6). +30. **CLI typing under parallel load.** The CLI's echo of a 200-character `/c` link paused for more than the 200 ms "line unchanged" window when 4 JVMs ran together. The window is now 1.5 s (it only matters on a mismatch), and failures report what the input line held. +31. **App-code additions are kept minimal** (user rule, 2026-10-14). Keys go only on elements a test acts on, or whose text cannot be read otherwise. Observation-only helpers were removed: the `uiMarker` modifier, row icon marks, a key attached only while an error shows, and a QR key. The tests assert those states through the model (`contact.verified`, `chatInfo.incognito`, `draftChatId`, chat item content), visible texts, or existing content descriptions (`seesDescription`, added to both harnesses). + +32. **Taps landed on a nested control's touch target.** The chat-list title tap hit the subscription indicator: Compose inflates a small clickable to a 48 dp touch target, so a point outside its drawn bounds still belongs to it. `tapPointInNode` now inflates nested clickables to 48 dp before choosing a free point. +33. **Desktop has no Back handler on some screens.** The New chat sheet and the pending-connection info ("You invited a contact", opened with `showClose = appPlatform.isAndroid`) register no `BackHandler` on desktop, so Esc does nothing there. The sheet has a "Back" arrow, so `goBack()` clicks `described("Back")` on desktop and presses system Back on Android. The pending-connection info has no close control on desktop; it stays in the center pane until another chat opens, so `closePendingConnectionInfo()` closes it on Android only. Because the panel stays open on desktop, the app turns it into the contact's chat when the peer connects (`replaceConnReqView`), and the open chat is always listed whatever the search (`ChatListView.kt` `filteredChats`); `searchFiltersByAlias` therefore asserts "listed for non-matching text exactly when it is the open chat", which holds on both platforms. This was the cause of the last 3 desktop failures in run b18 (`searchFiltersByAlias`, `searchMatchesPendingConnectionAlias`, `pendingConnectionMenuSetName`). +34. **`described(text)` needs the merged tree.** A content description on an `Icon` inside an `IconButton` is merged into the button; the unmerged tree has the description on a non-clickable node. Described keys match `hasContentDescription + hasClickAction` and use the merged tree for the tap point. +35. **Clicks during a user switch were lost.** `UserPickerScreen.switchTo` waits for `switchingUsersAndHosts` to clear and then for `quiesce()`. +36. **Peer line matching is shared.** `PeerLines` (uiTestShared) holds `sends/expects/eventually*`; desktop and Android `CliPeer` only implement `fill()` and `type()`. The Android bridge socket has a 60 s read timeout so a dead bridge fails the test instead of hanging it. +37. **Test hooks are off in release builds.** `UiIdle` and `Modifier.ui` do nothing unless `uiTestHooksEnabled` (set from `FLAG_DEBUGGABLE` on Android and by the desktop harness); `sendCmd` and the event loop wrap their bodies with `UiIdle.track`/`trackEvent`, which cost one volatile read when off. +38. **Android runner is portable.** `UITEST_SERIAL` selects any adb device and skips Waydroid; `ADB`, `JAVA_HOME`, `UITEST_APP_ID`, `WD_INSTANCE`, `WD_OWNER` are read from the environment; the runner refuses to start before `scripts/uitest.sh` has written `env.properties`. +39. **A new key parameter after a trailing lambda broke Android-only callers.** `uiKey` was added after `onChange`/`selectItem` in `SettingsPreferenceItem`, `SharedPreferenceToggle` and `SelectItemAction`; `androidMain` callers that pass the lambda as a trailing lambda (`Appearance.android.kt`, `PrivacySettings.android.kt`) stopped compiling, and the desktop suite does not compile `androidMain`. Rule: a new `uiKey` parameter goes before the last function-typed parameter, and every app-code change is compiled for Android (`android.sh`) as well as desktop before a run is reported. +40. **First full Android run of the shared tests (127 tests, 22 min, 121 passed).** Android-only causes of the 6 failures: + - Back on the pending-connection info first closes the soft keyboard ("Set contact name" focuses the name field), so one Back left the screen open; `closePendingConnectionInfo()` taps the screen's own "Back" arrow (`described("Back")`) on Android. + - Confirming a contact link plan opens the prepared chat full screen on Android, covering the chat list; `contactMenuForNotReadyContactOnlyDelete` calls `backToChatList()` before opening the row menu (no-op on desktop, where the list stays visible). + - The "Paste link / Scan" banner opens the QR scanner; Waydroid has no camera, so CameraX fails and the app crashes when the scanner is disposed (15.5 item 4). `connectBannerCardOpensPasteLink` calls `assumeCamera()` (skipped on devices without a camera; no-op on desktop). +41. **`chatCloseStore` failures were ignored.** It returns an error text ("" on success); the resets deleted the database files whatever it returned. One desktop run (b21) had `apiNewGroup` fail with "SQLite3 returned ErrorIO … disk I/O error" right after a reset (first time in about 20 runs; the disk was not full). Both harnesses now fail the reset when `chatCloseStore` returns an error, so a recurrence names its cause. The I/O error itself is not explained yet (open item). + +### 15.4.1 Open flake: a CLI member's group join not completed (desktop, 2 of 5 runs) + +- `MemberInfoTest.memberInfo_changeRoleToAdmin` (run b18) and `memberInfo_removeMember` (run b15): bob sends `/j team` and never prints "#team: you joined the group" within 60 s (30 s in b15). No API call is in flight in the app. +- Bob's agent database after the failure: the group connection's send queue is `confirmed` and its receive queue is `new`, so bob's confirmation was sent and alice's app never replied (no HELLO/secure). Alice's database was reset by the next test, so her side was not inspected. +- Not reproduced in isolation yet. Next step: keep the app database of a failing test (copy before reset) and check whether alice's agent received the CONF and whether the chat layer called `allowConnection`. + +### 15.4.1a Open flake: group row preview not updated (Android, 1 of 2 runs) + +- `ChatListRowsTest.previewGroupSenderPrefix` (full Android run, 2026-10-14 21:07): after alice leaves the group chat, bob sends "#team hello all"; for 10 s the `team` row showed the unread badge "1" but kept the preview "mine" (failure screenshot). Passed in the rerun of the class. +- The badge proves the row recomposed with new chat stats; the preview text comes from `chat.chatItems.lastOrNull()` (`ChatPreviewView.kt:228`). Either the new item did not reach the chat's preview list while its stats did (app-side model update), or an earlier group event incremented the counter and the message was still in transit. Not resolved; next step is to capture `chatModel.chats` for the row on failure. + +### 15.4.2 Test strength limits (second review, 2026-10-14) + +Under the minimal-app-code rule (item 31) some states are asserted through the model, not the UI. These tests pass if the model is right even when the UI fails to render the state: +- row icons without a description or key: `incognitoIconOnRow`, `verifiedIconOnRow`, the link image in `previewLink`, the edit icon in `previewShowsDraft`, the badge colour in `unreadCounterGrayWhenMuted`, mark-unread badges (`ChatListMenuTest`, `ChatListMenusPendingTest`); +- `ChatListScreen.gone()` checks the model only (the list is rendered from the same model); +- `profilesAddProfile` checks `chatModel.users`; +- `createListDuplicateNameError` cannot see the error text because it is always composed (transparent when hidden, `TagListView.kt`). +Harness behaviours that can hide an app defect: +- `enterWhenSendable` presses Enter again if the field has not cleared after 5 s (needed for 15.5 item 2), so a dropped first Enter is not reported; +- Android `pressEnter` on the message field taps Send; desktop `escape()` invokes the window back stack directly instead of sending an Esc key event; +- `isEnabled` is always true for `SectionItemView` keys (a disabled item drops `clickable` and sets no Disabled semantics). +Known harness limits (accepted for P1a): +- the known-issue allowlist cannot tell a body-time "database closed" from a teardown one, because Compose reports uncaught coroutine exceptions only when the test ends; +- Android `clipboardText()` falls back to the device clipboard, which is not cleared between tests; +- `OnboardingSharedState.selectedOperatorIds` is not reset between tests (no current test depends on it); +- `:common:desktopUiTest` run without `scripts/uitest.sh` is skipped and reports success with zero tests; +- on the Android path the test SMP server listens on all host interfaces with the repo's fixture TLS key, and `pm clear` acts on whatever `UITEST_APP_ID` names; never point `UITEST_SERIAL` at a personal device with the release package. +Fixed from the second code review: `UiKey` is a `data class`, so keys passed to composables compare equal and do not break recomposition skipping (keys are built in release too); the Android bridge client drops its socket after an IO error or EOF so the next call reconnects instead of reading a stale reply, and the bridge keeps accepting after a client drops; `uitest-android.sh` exits non-zero when it gives up after 4 crashed passes, runs its cleanup (bridge, Waydroid claim) on Ctrl-C/TERM, and rebuilds the bridge when `PeerLines.kt` changes; `uitest.sh --inside` refuses to run unless it is PID 1 of the runner's PID namespace (its TERM trap kills `-1`). +Fixed in the same review: `createGroup_opensChatAndInviteSheet` now checks the open chat, `search_direct_filtersResults` checks the matching item nodes, onboarding `!isEnabled` checks wait for the node first, the Android `TagEditor.Emoji` key was removed (no Android test acts on it), and the desktop scroll margin is 48 dp, not 50 px. + +### 15.5 App findings from the UI tests (2026-10-14; not fixed, reported only) + +1. **The group mute setting can revert in the UI right after joining a group** (catalogue 01:86, `groupMenuMuteModesAllMentionsNone`). + - What was seen: alice joins bob's group and changes the mode from the row menu (All → Mentions). The model shows Mentions once `apiSetSettings` returns, then about 0.5 s later shows All again, with no further user action. It happened in 2 of about 12 runs. Each time, the model's `chatSettings.enableNtfs` was captured in the failure message. + - Likely cause: a group event (`UserAcceptedGroupSent` or `UserJoinedGroup`, `SimpleXAPI.kt:3150,3278`) carries a `GroupInfo` read before the settings write, waits in the receiver queue, and is applied after the command reply with `updateGroup(...)`, overwriting `chatSettings`. + - Effect: the chat list shows the old mode. Notification decisions read the model, so a just-muted group can still notify until the app reloads its chats. + - Not yet confirmed on the core side: the core's stored setting was correct (Mentions) in the probe runs without the revert. + - The test now waits for event traffic to stop (`quiesce()`: no API in flight and no events for 1 s) before changing modes. + - Fix idea, not done: event-driven `updateGroup` keeps the model's `chatSettings` when it is newer than the event, or the settings command response is applied through the same ordered queue. + +2. **The first tap after typing in a text field is lost on desktop** (harness-visible; the real-mouse impact is unknown). Focus moves to the tapped control, and the focus change restarts its pointer handler mid-press. The harness requests focus on the target first (`focusBeforeTap`). Whether a real mouse hits the same problem is not verified; a manual check is needed before reporting it as an app bug. + +3. **The onboarding passphrase screen starts chat in the background when disposed** (`SetupDatabasePassphrase.kt:84-95`, `DisposableEffect.onDispose` → `withBGApi { startChat(user) }` when `chatRunning != true`). + - Two effects were seen: + - after "Encrypt", this races with the encrypt flow's own `startChat(newKey)`, and one of them fails with "failed to list users … database closed" (seen in `onboardingPassphrasePersistsAcrossRestart`); + - if the screen is disposed while no controller exists, `startChat` fails with "Controller is not initialized". + - Both escape as uncaught coroutine exceptions. In the app they are probably logged ("E: failed starting chat"); a manual check is needed to see whether a user sees an alert. + - Allowlisted in the harness as a known app issue (15.4 item 28). + +4. **Leaving the QR scanner crashes the app when the camera cannot be initialised** (`QRCodeScanner.android.kt:57-60`). `onDispose` calls `cameraProviderFuture?.get()?.unbindAll()`; when CameraX initialisation failed (no camera; on real devices possibly a camera held by another app or a camera service error), `get()` throws `ExecutionException(InitializationException(CameraUnavailableException))` during disposal, which is uncaught. Seen on Waydroid (0 cameras) in `connectBannerCardOpensPasteLink`; the activity then never reaches DESTROYED. The listener at :89-124 calls `get()` the same way. Fix idea, not done: catch the failure around `get()` in both places. + +### 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. +- 01:34: on desktop with no conversations the app then shows the "Talk to someone" connect-onboarding view, and the chat-list title and search are hidden (`shouldShowOnboarding()`, `OnboardingCards.kt:52`), not "the chat list". +- 01:37: `TestServers.configure()` (not `useLocalServers()`). +- The other corrections drafted per batch are in `~/uitest-drafts/b*/*/NOTES.md`, sections "Catalogue corrections". They are applied to the catalogue files after each batch passes. + diff --git a/plans/ui-tests/2026-10-12-android-research-progress.md b/plans/ui-tests/2026-10-12-android-research-progress.md new file mode 100644 index 0000000000..442e0f6864 --- /dev/null +++ b/plans/ui-tests/2026-10-12-android-research-progress.md @@ -0,0 +1,68 @@ +# Android UI test automation research — PROGRESS log + +## Next steps (keep current) +Investigation DONE (2026-10-12 15:50). Findings: plans/ui-tests/2026-10-12-android-ui-test-automation.md. Tool: ~/waydroid/wd.sh + ~/waydroid/README.md. +Open follow-ups (need user decisions/builds, not started): +1. Build an androidTest APK (fix testInstrumentationRunner -> androidx.test.runner.AndroidJUnitRunner, add ui-test-junit4) and run one createAndroidComposeRule test on Waydroid via `am instrument` (unverified so far). +2. Peer bridge + runner script (design in findings §5.1). +3. Truly-clean offline reinstall from ~/waydroid/debs untested (only simulated). +4. libhoudini A/B vs libndk. + +## State (what is running / changed) +- Installed on root FS (non-persistent): waydroid 1.6.2, weston 10, lzip, lxc etc. (28 debs cached in ~/waydroid/debs). nft rule `ip qubes custom-input iifname "waydroid0" accept`. Waydroid net is OPEN (not isolated). +- All Waydroid state now in ~/waydroid (var/ bind-mounted on /var/lib/waydroid, images/, translation/, waydroid_script/, venv/, downloads/ (1 GB OTA zips, deletable)); Android /data in ~/.local/share/waydroid (contains the SimpleX debug app with test profile "alice uitest"; no real user data). +- Waydroid + weston are RUNNING and CLAIMED by another session ('pr7679', since 15:44) — left running on purpose; do not `wd.sh down` while claimed. Stop: `WD_OWNER=pr7679 ~/waydroid/wd.sh down` only after that session is done (or claim expires after 30 min idle). +- Emulator: not running (x27 was started for the probe and shut down). +- ~/android-uitest-research/{waydroid-var,waydroid-images,waydroid_script,venv,dl} were MOVED to ~/waydroid (no longer here). + +## Log +- 13:30 Host facts: Qubes AppVM `claude-8tb`, Debian 12, kernel 6.18.51 qubes, Intel i7-10700 (avx2), 8 cpus, 12 GB RAM, no swap, NO /dev/kvm. sudo works without password. Root FS 25G (7.9G free, non-persistent AppVM), /home 1.8T. +- Kernel: CONFIG_ANDROID_BINDER_IPC=y, CONFIG_ANDROID_BINDERFS=y (binder in /proc/filesystems) => Waydroid's kernel requirement is met. Cmd: `grep binder /proc/filesystems; grep BINDER /boot/config-$(uname -r)`. +- Waydroid is NOT installed here (`which waydroid` empty, no /var/lib/waydroid, no ~/.local/share/waydroid). Earlier Waydroid use was in ANOTHER qube: ~/QubesIncoming/waydroid/{output.txt,attach-full.txt} are logcats sent from it (LineageOS 18/20-based Waydroid, APKs in /storage/emulated/0, package chat.simplex.app.debug ran). Translation layer not identifiable from the logcat (no houdini/ndk strings). +- Internet works (curl https://repo.waydro.id -> 200). +- Emulator: AVDs x27 (API27 x86_64 default), x30 (API30 x86_64 default), t27 (arm64), Medium_Phone (API 36.1 playstore). Memory note says only x27 boots with -accel off. +- APKs: many ~/android-arm64-v8a-debug-*.apk; newest master: ~/android-arm64-v8a-debug-master-7c2c750.apk (Oct 12). +- 13:35 Emulator API30 x86_64 'default' image: build.prop abilist=x86_64,x86 only, no native bridge => cannot run arm64 libsimplex.so. Emulator path for the REAL app is dead without an x86_64 APK (and TCG is slow). +- 13:36 apt candidates: lxc 5.0.2, dnsmasq, weston 10, cage 0.1.4 available in Debian 12. Waydroid from repo.waydro.id (bookworm). +- 13:38 Installed waydroid 1.6.2 + weston (scripts/01-install-waydroid.sh). OK. +- 13:40 scripts/02-init-waydroid.sh started (`waydroid init -s VANILLA -i ~/android-uitest-research/waydroid-images`, /var/lib/waydroid bind-mounted from /home). Download takes >10 min. +- ~/build/android.sh: arm64-v8a only, prebuilt ~/libsimplex.so + ~/libsupport.so (checks ELF is AArch64), never builds the core for Android. +- 13:45 waydroid init download is slow (~0.4-0.5 MB/s from ota.waydro.id); system zip in /var/lib/waydroid/cache_http (bind-mounted to ~/android-uitest-research/waydroid-var/cache_http). +- 13:46 Cloned casualsnek/waydroid_script to ~/android-uitest-research/waydroid_script; venv with deps at ~/android-uitest-research/venv (run: `sudo ./venv/bin/python waydroid_script/main.py -a 13 install libndk`). scripts/03-prefetch-translation.sh prefetches libndk + libhoudini zips (Android 13) into ~/.cache/waydroid-script/downloads (running in background). +- 13:47 App code (master d72f68e6d, ~/wt-uitest-ref): 0 testTag uses; no testTagsAsResourceId; android/src/androidTest has only ExampleInstrumentedTest.kt; testInstrumentationRunner = OLD "android.support.test.runner.AndroidJUnitRunner" (must become androidx.test.runner.AndroidJUnitRunner); androidTest deps: androidx.test.ext:junit 1.2.1 + espresso 3.6.1; ui-test-junit4 commented out. Compose 1.8.2, Kotlin 2.1.20, minSdk 26, targetSdk 36. +- 13:48 No x86_64-android core target anywhere: flake.nix has only aarch64-android and armv7a-android(-prebuilt) cross targets; no nix on this host. android build.gradle.kts splits = arm64-v8a, armeabi-v7a only. +- 13:48 APK ~/android-arm64-v8a-debug-master-7c2c750.apk: package chat.simplex.app.debug, 7.1-beta.6 (386), debuggable, native-code arm64-v8a only (libsimplex.so 203 MB). +- 13:46 Killed `waydroid init` (serial 0.45 MB/s; `sudo pkill -f 'waydroid init'` also killed my own shell -> exit 144; use pgrep by comm). Saved partial system zip as dl/system.partial (485 MB prefix). +- 13:47 scripts/02-fetch-images.sh: parallel range download from the sourceforge mirror (OTA json gives URL/sha256/size), resumable per part. Mirror-redirect resolution alone took ~4 min (cached in dl/*.url). +- 13:58 Vendor zip complete + sha256 OK (dl/lineage-20.0-20260927-MAINLINE-waydroid_x86_64-vendor.zip). System parts ~108 MB of 362 MB remaining fetched by 14:07; aggregate bandwidth only ~0.3-0.5 MB/s (other sessions are downloading heavily). +- 13:45 libndk (17.8 MB) + libhoudini zips prefetched OK into ~/.cache/waydroid-script/downloads. +- 14:26-14:29 Emulator probe (scripts/10-emulator-probe.sh x27): x27 boots headless under TCG in 149 s (evidence/emulator-x27/boot.txt); abilist x86_64,x86, ro.dalvik.vm.native.bridge=0 => no ARM translation. `adb install` of the 266 MB arm64 APK hung >16 min under TCG (killed by me); would fail NO_MATCHING_ABIS anyway. Emulator shut down (no emulator running now). +- 14:35 System zip assembled but sha256 mismatch: cause = the 2nd download run was killed by `timeout` while its curl children kept appending to the same part files as the 3rd run (concurrent appends). Located via zlib inflate error (offset 624361540) and patched 1 MiB blocks from the mirror (scripts/02d-repair-zip.py); sha256 now matches OTA id 053552725b... +- 14:44 Images unzipped: waydroid-images/system.img (1.83 GB), vendor.img (562 MB). Download zips kept in dl/ (can be deleted). +- 14:45 `waydroid init -f` from preinstalled images via /etc/waydroid-extra/images symlink (scripts/02-init-waydroid.sh): OK, no download. waydroid_base.prop: ro.hardware.gralloc=default, ro.hardware.egl=swiftshader (auto, no /dev/dri), ro.adb.secure=1. +- 14:45 libndk installed: scripts/04-install-translation.sh libndk -> abilist x86_64,x86,arm64-v8a,armeabi-v7a,armeabi; native.bridge=libndk_translation.so. +- 14:46 weston 10 needs `--use-pixman` (not --renderer=pixman); `waydroid shell` needs `--` before the command. +- 14:50 Session start (scripts/05-start-session.sh): boot_completed after 74 s (first boot). Container got NO IPv4: Qubes nft `ip qubes input` policy drop blocks DHCP from waydroid0. Fix: scripts/06-net-allow.sh (`nft add rule ip qubes custom-input iifname "waydroid0" accept`) -> eth0 192.168.240.112. +- 14:53 scripts/07-adb-connect.sh: key appended to /data/misc/adb/adb_keys, `adb connect 192.168.240.112:5555` -> device lineage_waydroid_x86_64. +- 14:55-15:06 `adb install -r -g ~/android-arm64-v8a-debug-master-7c2c750.apk` hung 11 min: Waydroid had FROZEN the container (suspend_action=freeze, screen-off/idle). Fix: `sudo waydroid container unfreeze; waydroid prop set persist.waydroid.suspend false; adb shell 'svc power stayon true; settings put system screen_off_timeout 2147483647'`. Install then finished immediately: "Success" (evidence/waydroid/install.txt). +- 15:03 PROOF: `am start -W -n chat.simplex.app.debug/chat.simplex.app.MainActivity` -> COLD start TotalTime 2559 ms; real arm64 core runs under libndk (logcat "SIMPLEX: started server/receiver"). After ~6 s "Opening database…" then onboarding (evidence/waydroid/01-launch.xml, 03-onboarding.xml). +- 15:05 `adb shell screencap` FAILS in Waydroid (rc=1, 0-byte file; SurfaceFlinger "no identification data"). Plan: weston --debug + weston-screenshooter from the Wayland side. +- 15:06 `uiautomator dump /dev/tty` works, ~2.2 s. Compose nodes: clickable View nodes have NO text and NO resource-id (text is on a non-clickable child TextView) -> today a host driver must tap the bounds of the text node / its clickable ancestor; testTag would be invisible without testTagsAsResourceId. +- 15:07 Interaction proof: tap (360,1284) "Get started" -> "Your profile" screen (04-*.xml); tap EditText, `input text alice`, keyevent 111 (ESC hides IME), tap "Create profile" bounds -> "Your network / Setup routers / Continue" screen (05-, 06-*.xml). Core created the user under translation. +- 15:05 NET (scripts/20-net-probe.sh, evidence/net/net-probe.txt): (a) device -> host server bound on bridge IP 192.168.240.1 works; (b) `adb reverse tcp:5223 tcp:8766` -> host 127.0.0.1-only server works (device connects to 127.0.0.1:5223; note toybox nc needs stdin kept open); (c) 127.0.0.1-only host server is NOT reachable via bridge IP (expected); (d) container HAS internet (ping 1.1.1.1 ok) through Qubes masquerade -> tests need an nft forward drop for isolation. +- 15:10 New requirement: durable tool at ~/waydroid (wd.sh + README). Cached 28 debs (8.5 MB) for offline reinstall in ~/waydroid/debs (list from /var/log/apt/history.log). Wrote ~/waydroid/wd.sh (setup/up/down/status/who/claim/release/wait-free/install/launch/reset-app/shot/dump/nodes/find/tap/tap-text/text/key/logcat/shell/reverse/net). Next: stop the research session, move images/var/translation/waydroid_script/venv into ~/waydroid, re-run from env -i shell. +- 15:09 Moved state into ~/waydroid: var/ (bind-mounted to /var/lib/waydroid), images/, translation/ (libndk+houdini zips), waydroid_script/, venv/, downloads/ (OTA zips), debs/; added fetch-images.sh + repair-zip.py. Research dir keeps only notes/scripts/evidence. +- 15:09 First `env -i ... wd.sh up`: setup ran (packages present, bind mount, symlink, translation already there), Android booted in 38 s. +- 15:12 BUG found+fixed: weston and `waydroid session start` started under `up` inherited the flock fd -> every later wd.sh command blocked on the lock. Fix: `{LFD}>&-` on both background launches (wd.sh lines ~116/123). (Also: `pgrep -f 'wd.sh …'` self-matched my shell again -> exit 144; use pgrep -x.) +- 15:13 Running scripts/30-wd-e2e.sh in env -i (down, up, install, reset-app, launch, shot, dump, nodes, tap-text, text, key, create profile) -> evidence/wd-e2e/30-e2e.log. +- 15:20 e2e run #1 issues: (1) tap-text failed "DEV: unbound variable" (dump in subshell) -> fixed (need_dev first); (2) screenshots black 3113 B: `waydroid session start` creates NO window; `waydroid show-full-ui` does, but weston kiosk-shell never mapped the Waydroid toplevel (scene-graph empty although the client attached 720x1440 shm buffers). Fix: weston desktop-shell + ~/waydroid/weston.ini (panel-position=none) and `up` uses `waydroid show-full-ui`. Screenshot now works (evidence/wd-e2e/15-desktop-shell.png home screen; 17-app-later.png SimpleX onboarding; screenshot pixel coords == uiautomator coords, e.g. "Get started" at 360,1284). +- 15:21 Funny side effect seen: `input text "alice uitest"` with no focused field: SPACE activated the focused Compose button ("Get started"). wd.sh tap-text into the field first. +- 15:28 e2e run #2: `up` failed "umount /var/lib/waydroid/rootfs" (stale rootfs mount left by a quick down/up). Fixed: `down` waits for the session to stop and unmounts rootfs; `up` unmounts a stale rootfs (umount -R, lazy fallback). +- 15:37 e2e run #3 (scripts/30-wd-e2e.sh under `env -i`, log evidence/wd-e2e/30-e2e.log): ALL STEPS rc=0. down 2 s; up 54 s (boot); install 8 s (266 MB APK, already-installed update); reset-app (pm clear) 1 s; launch 3 s (cold); onboarding visible ~45 s after launch on the freshly-cleared app (first DB creation under translation); shot ~0.5 s; dump 2 s; nodes 3 s; tap-text "Get started" -> profile screen; tap-text "Enter profile name…", text "alice uitest" -> 11-profile-typed.png shows the text + IME; tap-text "Create profile" -> 12-after-create.png "Your network / Continue". Note: KEYCODE_ESCAPE does not hide the IME (keyboard stays up; harmless here). +- 15:42 Claim/lock test (evidence/wd-e2e/40-claim-net.txt): other owner and anonymous mutating calls rejected while claimed; owner allowed; read-only allowed; release works. `wd.sh net isolate` -> ping 1.1.1.1 blocked, `net open` restores. Net probe while isolated (evidence/net/net-probe-isolated.txt): bridge server + adb reverse still work, internet blocked. +- 15:43 Simulated qube restart (umount var, rm /etc/waydroid-extra/images, delete nft rule) then `env -i wd.sh up` -> auto-setup + boot in 56 s (evidence/wd-e2e/50-up-after-simulated-restart.txt). Offline deb reinstall only simulated (`apt-get install -s --no-download debs/*.deb` resolves; packages were already installed, so a real from-scratch offline install is UNTESTED). +- 15:44 App data persisted across down/up (onboarding stage "Your network"). +- 15:44 ANOTHER SESSION ('pr7679') has claimed the instance and is driving it (`tap-text Continue`). I stopped all device actions. Waydroid is left RUNNING for them (not stopping it: someone else is using it). +- Removed stale 463 MB partial zip from ~/waydroid/var/cache_http. Wrote ~/waydroid/README.md. +- Compose ui-android 1.8.2 (gradle cache) has androidx.compose.ui.semantics.testTagsAsResourceId (SemanticsProperties_androidKt.setTestTagsAsResourceId). ui-test-junit4 is NOT in the gradle cache (a build would download it). +- 15:50 Wrote final findings doc plans/ui-tests/2026-10-12-android-ui-test-automation.md (incl. "Same tests as desktop?" §5, catalogue carry-over via scripts/40-catalog-codes.py: 1,167 names; P 758, — 238, FD 39, RELAY 90, CB 90, IGNORE 28; 04 §K remote desktop 17 desktop-only). Done. diff --git a/plans/ui-tests/2026-10-12-android-ui-test-automation.md b/plans/ui-tests/2026-10-12-android-ui-test-automation.md new file mode 100644 index 0000000000..c05c52b382 --- /dev/null +++ b/plans/ui-tests/2026-10-12-android-ui-test-automation.md @@ -0,0 +1,317 @@ +# Android UI test automation on this host + +**Date:** 2026-10-12. **Status (2026-10-14):** the instrumented harness (layer (a), 3.1) is built and running in `~/wt-uitest`: 127 Android tests (112 shared with desktop + 14 `BackKeyTest` + `SmokeTest`); what is built is described in the desktop plan, section 0.5 (`plans/ui-tests/2026-10-10-desktop-ui-test-dsl-plan.md`), and run findings are in §8. Sections 0–7 below are the 2026-10-12 investigation, with notes where the build differs. +Running log: `plans/ui-tests/2026-10-12-android-research-progress.md`. Evidence: `~/android-uitest-research/evidence/`. Tool: `~/waydroid/` (`wd.sh` + `README.md`). + +## 0. Summary + +- **Runtime: Waydroid works, the emulator does not.** On this KVM-less qube, Waydroid (LXC, LineageOS 20 / + Android 13 x86_64, libndk arm64 translation) runs the real arm64-only SimpleX debug APK headless: + - Android boots in 37–56 s; the 266 MB APK installs in 5–8 s; the app cold-starts in 2.2–2.6 s. + - The Haskell core works under translation. After `pm clear`, the first database creation takes ~45 s (measured later inside the instrumented harness: the app's startup took 15.4 s, plan 15.2). + - The emulator only runs x86_64 AVDs under TCG with no ARM translation (x27 boots in 149 s). The real app can't run on it. +- **Proof:** a clean-environment script installed, reset and launched the app, took screenshots, dumped the UI + hierarchy, tapped through onboarding, typed a name and created a profile, all with rc=0 + (`evidence/wd-e2e/30-e2e.log`, PNGs `10-`, `11-`, `12-*.png`). +- **Networking:** Android reaches host servers on the bridge IP (192.168.240.1), and loopback-only host servers + through `adb reverse`. Both still work with the internet blocked for the container (`wd.sh net isolate`). +- **Automation layer, recommended: instrumented Compose tests** (`createEmptyComposeRule()` + `ActivityScenario.launch(MainActivity)` after the reset, `05-android-only.md` setup item 4; run with + `am instrument`) behind the desktop plan's `UiDriver` (as built: behind per-platform `UiScope`s with the same methods, no `UiDriver`). Test bodies, screen objects and `Ui` keys are shared, and + only the driver and the peer transport differ. CLI peers and `uitest-servers` stay on the host and are reached + through `adb reverse`. + - A host-driven layer (uiautomator/adb, Maestro, Appium) works today with no app change, which `wd.sh` proves. + It suits LLM-guided exploratory testing, but it can't see typed semantics, has no idling, and costs 2–3 s per observation. +- **Built (2026-10-13/14):** + - the `androidTest` source set: `testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"`; added `androidx.test:runner` 1.6.1, `androidx.test:core` 1.6.1, `espresso-intents` 3.6.1 and `ui-test-junit4` 1.8.2 (with the existing `ext:junit` and `espresso-core`); `common/src/uiTestShared/kotlin` added as an `androidTest` source dir; + - no shared `UiDriver`: each platform has its own `UiScope` with the same method names, and the shared screens and test bodies compile against either; + - the host peer bridge (§5.1, as built); + - the runner `scripts/uitest-android.sh` (§5.1, as built). + - Not built: `testTagsAsResourceId` and `ui-test-manifest` (not needed by the instrumented layer). + - An x86_64 APK is **not needed** and would be expensive: no x86_64-android GHC cross target exists. + +## 1. Host facts + +- Qubes AppVM `claude-8tb`: Debian 12, kernel 6.18.51 (qubes), Intel i7-10700, 8 vCPU, 12 GB RAM, no swap, + **no `/dev/kvm`**, no `/dev/dri`. The root FS is non-persistent (25 GB, reset on qube restart); /home is 1.8 TB. +- The kernel has `CONFIG_ANDROID_BINDER_IPC=y` and `CONFIG_ANDROID_BINDERFS=y`, so Waydroid needs no kernel modules. +- Before today Waydroid was not installed on this qube. The earlier Waydroid use (PR #7570 attach sheet) was in + another qube; its logcat is `~/QubesIncoming/waydroid/attach-full.txt`, and it showed the same SwiftShader/gralloc stack. + +## 2. Runtime: Waydroid vs emulator + +| | Waydroid (chosen) | Android emulator | +|---|---|---| +| Real SimpleX APK (arm64-v8a only) | **runs** (libndk translation, `ro.product.cpu.abilist=x86_64,x86,arm64-v8a,…`) | **no**: x86_64 AVDs have no native bridge (`ro.dalvik.vm.native.bridge=0`, `evidence/emulator-x27/boot.txt`); arm64 AVDs can't run without KVM ("QEMU2 emulator does not support arm64") | +| Boot | 37–56 s warm, ~75 s first boot | x27: 149 s (TCG); x30 not tried (its image has no native bridge either); the API 36.1 image needs KVM (earlier note) | +| Install 266 MB APK | 5–8 s | >16 min, never finished (killed) | +| App cold start | 2.2–2.6 s (`am start -W`) | n/a | +| Headless | yes: weston headless backend + pixman, `waydroid show-full-ui` | yes (`-no-window`) | +| Screenshot | `weston-screenshooter` (weston `--debug`); `adb screencap` **fails** (empty file) | `screencap` works | +| UI dump / input | `uiautomator dump` 2–3 s; `input tap/text/keyevent` | same | +| Host servers | bridge IP 192.168.240.1 or `adb reverse` (both verified) | `10.0.2.2` or `adb reverse` | +| Internet isolation | `wd.sh net isolate` (nft forward drop); host still reachable (verified) | not examined | +| Multiple instances | **two** since 2026-10-14 (instance 1 shared, instance 2 for UI test runs; `~/waydroid2`, plan 15.4 item 15a); runs are serial within an instance | several AVDs, each slow | +| RAM | app process ~680 MB RSS / 490 MB PSS (`dumpsys meminfo`); whole container not measured | 2 GB guest RAM (AVD config) + QEMU | + +Gotchas found and solved, all handled in `wd.sh`: +1. **Qubes firewall:** the `ip qubes input` chain has policy drop, so the container gets no DHCP lease or IPv4. + Fix: `nft add rule ip qubes custom-input iifname "waydroid0" accept`. +2. **Container freeze:** Waydroid freezes the container when Android idles, and an `adb install` then "hung" for + 11 min. Fix: `waydroid prop set persist.waydroid.suspend false`, plus stay-awake settings and an unfreeze before + every device command. +3. **No window:** `waydroid session start` creates no window, so screenshots were black. `waydroid show-full-ui` + creates one, but weston **kiosk-shell never maps it** (empty scene graph, even though the client attached + 720x1440 shm buffers). Fix: weston desktop-shell with `panel-position=none`. +4. **Stale rootfs mount:** a quick down→up left rootfs mounted, and the next session failed on "umount rootfs". + `down` and `up` now unmount it. +5. **Lock fd inheritance:** daemons started while `wd.sh` held its flock inherited the fd and blocked every later + command. Fix: `{LFD}>&-`. +6. **Slow download:** `waydroid init` downloads serially at ~0.4 MB/s here. Replaced by a parallel, resumable range + download (`~/waydroid/fetch-images.sh`) and init from local images (`/etc/waydroid-extra/images`). + A corrupted part, caused by two downloaders appending to the same file, was repaired block by block (`repair-zip.py`). + +## 3. Automation layer + +### 3.1 Options evaluated + +| Layer | Works here? | Finds nodes by | Sync | Sees typed semantics / chatModel | Speed | App change | +|---|---|---|---|---|---|---| +| (a) Instrumented: `createEmptyComposeRule()` + `ActivityScenario.launch(MainActivity)` after the reset (`05-android-only.md` setup item 4) + Compose test APIs, `adb shell am instrument` | yes (built 2026-10-13; §8) | `testTag` (= `Ui` keys) directly, text, any `SemanticsMatcher` | Compose idling + the plan's polling (§6) | **yes** (same process) | ms per query | androidTest set-up only (keys from the desktop plan apply as they are in commonMain) | +| (b) Host-driven: `uiautomator dump` + `adb shell input` (what `wd.sh` does) | **yes, proven** | text / content-desc / bounds; `resource-id` only with `testTagsAsResourceId` | polling only | no (accessibility tree only) | 2–3 s per dump | none today; `testTagsAsResourceId` for keys | +| (c) Maestro / Appium (UiAutomator2 server) | likely (both just need adb), not tried | same accessibility tree as (b) | built-in waits | no | ~0.3–1 s per query (typical; not measured here) | same as (b) | + +Evidence for (b): +- Compose nodes appear as `android.view.View`. The clickable node has an empty text and no resource-id, and the + text sits on a non-clickable child `TextView` (`evidence/waydroid/03-onboarding.xml`). +- `wd.sh nodes` resolves this by tapping the nearest clickable ancestor. +- `testTagsAsResourceId` exists in the app's Compose (`androidx.compose.ui:ui-android:1.8.2`, + `SemanticsProperties_androidKt.setTestTagsAsResourceId`). One `Modifier.semantics { testTagsAsResourceId = true }` + on the root makes every `Ui` key visible as `resource-id` to (b) and (c). + +### 3.2 Reset, peers and servers + +- **Reset:** + - `pm clear chat.simplex.app.debug` takes 1 s, then the first launch takes ~45 s to create the database under + translation. This is too slow to run per test. + - Instrumented tests should reuse the desktop plan's in-process `resetForTest` (stop the core, delete the + database, start the core): sub-second apart from the core's own work. + - Or use AndroidX Test Orchestrator `clearPackageData` for full isolation at ~45 s per test. + - `run-as` is blocked on this Waydroid image (plan 15.4 item 13; an earlier note here said it works). Fixture database copies (plan `FIXTURE`) need another route; open. +- **CLI peers:** the CLI is a glibc x86_64 binary, so it runs on the host, not in Android. It reaches the same + servers as the app. +- **Servers:** `uitest-servers` on the host. The app reaches them through `adb reverse tcp:

tcp:

` per port, so + the **same server address** (`smp://…@localhost:

`) works for both the app and the host peers. The alternative + is binding them on `192.168.240.1`. +- **Isolation:** the desktop plan's loopback-only netns can't contain the container. The equivalent is + `wd.sh net isolate`: the container reaches only the host. (As built, the runner does not apply it yet; §6 item 3.c.) Host peers and servers can still run in a netns if the + adb server and their ports are exposed to it; the simplest form runs them on the host with `-s` pointing only at + local servers. + +### 3.3 Not verified (needs a build) — resolved 2026-10-13 + +Resolved: the runner class is fixed, `ui-test-junit4` 1.8.2 is added, and the instrumented tests run on Waydroid instance 2 (`SmokeTest` first, plan 15.2). The original text: + + +- No instrumentation APK was built or run (builds were out of scope). The current + `android/build.gradle.kts` has `testInstrumentationRunner = "android.support.test.runner.AndroidJUnitRunner"` + (stale support-library class; must be `androidx.test.runner.AndroidJUnitRunner`), and `ui-test-junit4` is + commented out (it is not in the Gradle cache either). +- Risk is low: the test APK is dex only and runs inside the app's (already translated) process. +- The first check after a build is `am instrument -w chat.simplex.app.debug.test/androidx.test.runner.AndroidJUnitRunner` + with one trivial `createEmptyComposeRule()` + `ActivityScenario.launch(MainActivity)` test on Waydroid. + +## 4. Proof run (evidence) + +`~/android-uitest-research/scripts/30-wd-e2e.sh`, run under `env -i HOME=/home/user PATH=/usr/bin:/bin:/usr/sbin:/sbin`. +Log: `evidence/wd-e2e/30-e2e.log`. + +| Step | Result | +|---|---| +| `wd.sh down` / `up` | 2 s / 54 s (boot), serial `192.168.240.112:5555` | +| `install ~/android-arm64-v8a-debug-master-7c2c750.apk` | Success, 8 s (7.1-beta.6, `chat.simplex.app.debug`) | +| `reset-app` (pm clear), `launch` | 1 s, cold start 2.2 s; onboarding after ~45 s (first database) | +| `shot` | `evidence/wd-e2e/10-onboarding.png` (SimpleX "Be free in your network", "Get started") | +| `dump`, `nodes` | `10-onboarding.xml`; "Get started" tap point 360,1284 (= pixel position in the PNG) | +| `tap-text "Get started"` → `tap-text "Enter profile name…"` → `text "alice uitest"` | `11-profile-typed.png` (name in field, IME up) | +| `tap-text "Create profile"` | `12-after-create.png`: "Your network / Setup routers / Continue" (core created the user under translation) | + +Also: +- `evidence/waydroid/*.xml`: the first manual run. +- `evidence/net/*.txt`: network probes, open and isolated. +- `evidence/wd-e2e/40-claim-net.txt`: lock/claim and isolate tests. +- `evidence/wd-e2e/50-up-after-simulated-restart.txt`: `up` re-running setup after the root-FS state was removed. + +## 5. Same tests as desktop? + +**Short answer: yes for test bodies, screen objects and `Ui` keys, if the Android runner is the instrumented +layer (5.1). The host-driven layer (5.2) can share the DSL's surface but not its assertions.** + +### 5.1 Instrumented tests behind `UiDriver` (recommended) + +- (As built: no shared `UiDriver`; each platform has a `UiScope` with the same methods, and the shared directory is added to `:android`'s `androidTest`, not `androidInstrumentedTest`. The message field is set with Espresso `replaceText` with retries, no `setSelection`, and read with a `ViewAssertion`; its hint (placeholder) is not read. Plan 0.5.4.) +- **Shared code.** The plan's `UiDriver` (§7.4) wraps Compose test APIs that exist in both runners: + `onNode(hasTestTag(key.tag))`, `performClick`, `performTextInput`, semantics reads, `waitUntil`. On desktop the + receiver is `SkikoComposeUiTest` (`runInternalSkikoComposeUiTest`, plan §7.2/§14); on Android it is the + `ComposeTestRule` from `createEmptyComposeRule()`. Both implement `SemanticsNodeInteractionsProvider`. + - Put the driver, the screen objects (§9.1) and the DSL (§9.2) in a source set compiled for both, e.g. a + `uiTestShared` directory added to both `desktopTest` and `androidInstrumentedTest`. + - The test classes (`OneTimeLinkCompatTest`, …) then run unchanged on both, except the tests `05-android-carryover.md` lists. + - `Ui` keys and typed semantics (§3–4) are in commonMain, so the app code needs nothing Android-specific. +- **Per-platform parts:** + - `escape()`: desktop invokes `simplexWindowState.backstack` (desktopMain, `D/DesktopApp.kt:32,272`) inside `runOnUiThread` (plan §7.4); on Android it is the system Back from the test thread (`Espresso.pressBack()`, 05 `BACK`, carry-over R9), not wrapped, and the IME and `MainActivity.onBackPressed` also act on it (05 §13); + - input threading: the desktop driver wraps input injection and the failure capture in `runOnUiThread` (plan §7.4, §6); on Android it must not — `AndroidInputDispatcher.flush` already injects on the main thread (ui-test 1.8.x, AndroidInputDispatcher.android.kt:547-565), and a perform, lookup or capture inside `runOnUiThread` throws "cannot be run from the main thread" (`EspressoLink.runUntilIdle`, EspressoLink.android.kt:75-83) — so the wrapper is a per-platform function that does nothing on Android; + - the test dispatcher: plan §14 chooses `StandardTestDispatcher` because the default unconfined one runs `snapshotFlow` collectors inside measure; the Android P1 checks what `createEmptyComposeRule` uses and whether a `TestDispatcher` can be passed (e.g. in `effectContext`), and if it can't, the 04 `console_*` rows take the plan's fallback on Android (read `chatModel.terminalItems`, scroll to the newest line); + - the compose field is an `EditText` inside `AndroidView`, so `performTextInput` can't reach it: typing goes through Espresso `replaceText` plus `setSelection`, reads by getting `EditText.text`/`.hint` in an Espresso `ViewAction` behind `ChatScreen.messageText`/`placeholder` (`05-android-only.md` setup item 5, `05-android-carryover.md` R12); + - boot/reset (a once-per-process boot step first waits for and stops the core that `SimplexApp.onCreate` started, and sets toolbar blur off in the baseline, as on desktop, `05-android-only.md` setup item 4; `resetForTest` calls the same commonMain core functions; it runs before `MainActivity` is launched with `ActivityScenario.launch`, under `createEmptyComposeRule()`, so the reset's prefs and UI-state clearing are in place before `onCreate`, `05-android-only.md` setup item 4); + - surfaces outside the scene (§7.6). File choosers become Espresso-Intents stubs instead of `UiFiles`; + `LocalUriHandler` changes too: it is Compose's `AndroidUriHandler`, which calls `startActivity` with `ACTION_VIEW`, + so links are recorded and stubbed with Espresso-Intents as well (`uitest-catalog/05-android-carryover.md` rule R13, + which also covers the clipboard); notifications are read in-process with `NotificationManager.getActiveNotifications()` + (`SYSNTF` in `05-android-only.md`). + - Keyboard shortcuts and right-click/hover are desktop-only (§5.3). +- **Peers and servers from inside the device.** The test JVM runs inside Android, so `CliPeer` (pty4j + VT model) + can't spawn the CLI there: it is a glibc x86_64 binary. Split `CliPeer` behind an interface: + - On desktop it stays in-process. + - On Android a host process, the **peer bridge**, owns the real `CliPeer`s, `RelayPeer`s and `uitest-servers`. + It is a small Kotlin/JVM main in the test tree that reuses the desktop `CliPeer` code. + - **As built** (`UiBridge.kt` in the desktop harness; client `Bridge` in the Android `CliPeer.kt`): a line protocol on + `127.0.0.1:7777`, one client at a time; the Android side uses a fixed port 7777 and a 60 s read timeout. + - `servers` → `ok ` (`xftp1` is configured but not served yet, plan 0.5.6); + - `start ` → `ok`, sent only after the peer has started + (synchronous; the peer's database is copied from the peer template of the run directory named in `env.properties`, when present); + - `type ` (verified typing, host-side) → `ok`; + - `drain ` → `lines N`, then the N new output lines; + - `stop ` → `ok`; any failure → `err `; when the client disconnects, the bridge stops all its peers. + - Comparison (`sends` echo, `expects`, `eventually`, `contactId`) runs **device-side** on the drained lines, in the + shared `PeerLines` class, so `bob expects "…"` has the same semantics as on desktop. + - The test connects through `adb reverse tcp:7777 tcp:7777`, and the server ports (base + 1, base + 2) are reversed as well. + - Proposed earlier and not built: `sends`/`contactId`/`expect … ` as bridge commands, `servers reset`, `snapshot`. +- **Runner.** (The package name `chat.simplex.app.debug` used throughout assumes `application_id.suffix=.debug` in `local.properties`, as `local.properties.example` sets it; root `build.gradle.kts:23-25`.) + - **As built:** `scripts/uitest-android.sh [am instrument -e …]`, with APKs from + `ANDROID_TEST=1 bash ~/build/android.sh`. It needs one earlier `scripts/uitest.sh` run (CLI, `uitest-servers`, + `env.properties`) and 4500 MB free. + - Environment: `UITEST_SERIAL` (any adb device; skips Waydroid), `WD_INSTANCE` (default 2), `WD_OWNER` + (default `uitest-$USER`), `UITEST_APP_ID` (default `chat.simplex.app.debug`), `ADB`, `JAVA_HOME`. + - It claims and boots Waydroid in the background, starts the bridge with `java -cp`, installs the APKs (the app APK + only when changed), sets the `adb reverse` ports, and per pass runs `pm clear`, grants permissions and + `am instrument -w -r`; after a process crash it resumes with `-e notClass` (at most 4 passes). + - Results are not JUnit XML: `build/uitest-android/run-/results.txt` (`# `: 0 pass, + −2 fail, −3/−4 skipped, `crashed`), plus `instrument*.txt`, `logcat.txt`, `timings.txt`, `failures/`, `bridge.log`. + The exit status is non-zero on any failure or crash. Details: plan 0.5.9. + - It does **not** run `wd.sh net isolate` (open item, §6 item 3.c). + - Considered and not used: `./gradlew :android:connectedFossDebugAndroidTest` (the app has `google`/`foss` flavors, + foss the default, `android/build.gradle.kts:40-50`). +- **Throughput limits.** + - Runs are serial within a Waydroid instance: no parallel JVMs as on desktop (§10.2). UI test runs use instance 2; instance 1 is shared with other sessions. + - In-process reset avoids the 45 s first-database cost per test. + - The core runs under arm translation; it measured fine for onboarding, but no figure exists yet for a full Android run (the 1,094 carried-over tests of 01–04, 18 of them `IGNORE`, plus the runnable tests of `05-android-only.md`). + +### 5.2 Host-driven over adb/UiAutomator(2) with `testTagsAsResourceId` + +- **Reach.** + - The same DSL surface can run in a host JVM, with peers local exactly as on desktop. + - The driver maps `Ui.X.Y` → `resource-id` (with `testTagsAsResourceId`), text → `text`, and click → `input tap` + at the node centre. `wd.sh` already does this from the shell. +- **What is lost.** + - Typed semantics (§4: unread count, item meta/status, sender, verification, incognito, muted) are custom + `SemanticsPropertyKey`s that are not exported to accessibility. Assertions on them would need the app to mirror + them into `stateDescription`/`contentDescription`, which changes what TalkBack reads. + - Name→id resolution through `chatModel` (§9.1 `row("bob")`) is lost; the alternative is text matching or + `/_get` via a peer. + - No Compose idling; every observation is a 2–3 s `uiautomator dump`, or less with a resident UiAutomator2 server (not measured here). + - Off-screen lazy-list items aren't in the tree, so scrolling is needed. +- **Fit.** Good for smoke tests and LLM-guided exploratory testing (`~/waydroid/README.md` "How an LLM agent should + drive the GUI"). Not a substitute for the catalogue's assertions. + +### 5.3 Catalogue carry-over (1,177 names; counts from `plans/ui-tests/catalogue/tools/needs.py`) + +| Group | Names | On Android | +|---|---|---| +| Rows needing only peers, servers, restart, fixtures, clipboard, toasts, URI (`P` 763, `—` 241, `RELAY` 90, `XFTP` 72, `RESTART` 48, `FIXTURE` 38, `CB` 90, …; overlapping) | ~1,050 | carry over through 5.1 with the peer bridge; `RESTART` rows only once `restart()` has an Android mechanism (`05-android-only.md` open question 14), `FIXTURE` rows only once fixtures have one (open question 16). Assertions on Android-specific layouts (one-pane navigation instead of the desktop's side-by-side list+chat, ChatListView.kt:612) need different screen-object steps, but test bodies stay the same if screen objects own navigation | +| `FD` file-dialog rows | 42 | port with an Espresso-Intents picker stub instead of `UiFiles` | +| `REC`/`AV` seams | 19 | port only once the §11.2 seams have Android actuals (MediaRecorder/ExoPlayer) | +| 04 §K Remote desktop "Link a mobile" (desktop is the controller) | 17 | **desktop-only**. Android is the *host* side ("Use from desktop"), a different test set with a CLI controller | +| 02 §W right-click and Enter rows, `IMGCB` Ctrl+V | 3 | desktop-only; the other §W rows run with long press or Back (`05-android-carryover.md`) | +| 04 §C SimpleX Lock (desktop passcode) | 22 | partially: Android has system-auth/biometric lock modes in addition to the passcode; 20 rows carry over with "Lock mode" set to "Passcode" first (map R8), and the relock and notice rows are replaced by `05-android-only.md` §10 | +| 04 §D Appearance with `FD` (wallpaper image, theme export/import) | 6 | via picker stub | +| `IGNORE` rows (window chrome, tray, OS drag-and-drop, WebRTC, public servers, `chatHooks`, …) | 28 | still ignored; window/tray/drag-and-drop ones are N/A on Android | + +**Android-only areas the catalogue doesn't have:** +- notification modes and the onboarding "Setup notifications" stage (periodic/instant service, battery-optimisation prompts); +- runtime permission prompts (POST_NOTIFICATIONS, camera, microphone); +- share-into-app intents and app-link opening (`am start -a VIEW -d https://simplex.chat/…`); +- back gesture / predictive back and IME-overlap layouts (e.g. PR #7570); +- rotation and split-screen (PR #7497); +- calls in the Android WebView; +- "Use from desktop" (host side); +- Waydroid limits: no camera, so QR scanning is out of reach; the MediaProvider NPE blocks file-send from the + system picker, so stub the picker in tests. + +## 6. Recommendation + +1. **Runtime:** Waydroid via `~/waydroid/wd.sh` (setup steps in `~/waydroid/README.md`; it re-provisions itself + after qube restarts from `~/waydroid`). Use the emulator only for framework-only questions with stub APKs, as before. +2. **Now, no app change:** LLM-guided and smoke GUI testing with `wd.sh nodes/shot/tap-text/text` on the existing + debug APKs. This is already usable and is shared between sessions with `claim`/`WD_OWNER`. +3. **Desktop plan alignment (after its P1 keys land):** (status 2026-10-14: a, b, c and d are done, except the items marked below; see §0 "Built" and plan 0.5) + - **a.** Android build set-up (done, except `ui-test-manifest` and `testTagsAsResourceId`, not built): + - fix `testInstrumentationRunner` to `androidx.test.runner.AndroidJUnitRunner`; + - add `androidTestImplementation("androidx.compose.ui:ui-test-junit4:")` and + `debugImplementation("androidx.compose.ui:ui-test-manifest:…")`; + - optionally add `testTagsAsResourceId = true` on the root in debug builds, for (b)/(c) and the LLM tool. + - **b.** Move `UiDriver`, the screen objects and the DSL into a source directory shared by `desktopTest` and + `androidInstrumentedTest`; keep boot, reset and surfaces per platform. (Done as `common/src/uiTestShared`, shared by + `desktopTest` and `androidTest`, without a `UiDriver` interface.) + - **c.** Peer bridge (host JVM main reusing `CliPeer`/`RelayPeer`/`TestServers`) plus a runner script: + `wd.sh up` → `wd.sh net isolate` → start bridge + servers → `adb reverse` ports → install app + test APK → + `am instrument` → collect JUnit XML. (Done with `CliPeer` and `TestServers`, no relays; results go to `results.txt`, + not JUnit XML. **The runner does not call `wd.sh net isolate`** (open item): the container keeps internet access + during runs, and isolation rests on the local server configuration and on onboarding opening no connection.) + - **d.** Start with the 3 example tests of plan §9.3 on Android; then measure per-test time under translation + before planning catalogue-wide runs. (Done: §8.) +4. **x86_64 APK: not worth it now.** + - `flake.nix` has only `aarch64-android` and `armv7a-android` cross targets, and nix is not installed here. + - `~/build/android.sh` packages prebuilt arm64 `libsimplex.so`/`libsupport.so` only. + - An x86_64 build would need a new haskell.nix `x86_64-android` cross toolchain (GHC, plus openssl, libffi, + gmp and iconv for x86_64-android), a CI job, and `x86_64` in the Gradle `splits`/`abiFilters`. + - That is days of toolchain work for a speed-up translation doesn't need: onboarding is already responsive. + - Revisit only if translation proves too slow for catalogue-scale runs. + +**Costs/risks:** +- runs are serial within an instance (two instances since 2026-10-14; UI test runs use instance 2); +- arm translation is fast enough for onboarding, but bulk message and file tests are not measured yet; +- Waydroid quirks (freeze, window mapping, stale mounts) are handled in `wd.sh`, though new ones may appear; +- the root FS is reset on restart, so the offline deb reinstall path is only simulated, not tested from a truly clean root; +- Android 13 only (one OS version); +- no camera. + +## 7. Decisions and open questions + +Decided (user, 2026-10-12): +- Test tags are for testing only, so they exist only in debug builds: `testTagsAsResourceId = true` is set on the root in debug builds only (decided, not built: the instrumented layer doesn't need it), and the shared `Modifier.ui` emits tags only when `uiTestHooksEnabled` (formerly `uiTagsEnabled`) is set: by `initUiTags(context)` from `SimplexApp` when the app is debuggable, and by the desktop harness (desktop plan §3.1, 0.5.2). Release builds carry no tags. Typed semantics are deferred (plan 13, minimal-app-code rule of 2026-10-14). +- Android-only UI gets its own catalogue, `plans/ui-tests/catalogue/05-android-only.md`. + +Decided (user, 2026-10-13; recommendations in plan section 15): restart is in-process (05 question 14; built, plan 15.4 item 10); the debug-only network-security-config is approved (15; not built yet); the `FIXTURE` proposal is approved (16; not built, and `run-as` is blocked); Android screenshots are deferred (17); the toast hook is added only if the on-device `UiAutomation` check fails (13). Android work started on 2026-10-13, after desktop P1a. + +Open: + +1. ~~Instrumented runner: Gradle `connectedFossDebugAndroidTest` against the Waydroid adb device, or a plain + `am instrument` runner script?~~ Resolved: the script, `scripts/uitest-android.sh` (§5.1). +2. ~~Is in-process `resetForTest` enough isolation on Android, or do some areas need Orchestrator `clearPackageData`?~~ + Answered in §8: in-process reset is enough so far; notification and service areas are not tested yet. +3. libndk vs libhoudini: only libndk was tested. Houdini is Intel's and often faster on Intel CPUs; worth one A/B + run of a message-heavy test later. + +## 8. Findings from running the harness (2026-10-14) + +The full list, with causes and fixes, is in plan §15.4 (`plans/ui-tests/2026-10-10-desktop-ui-test-dsl-plan.md`). The ones that change this write-up: +- **Open question 2 (in-process reset):** the reset isolates tests, but the translated core sometimes crashes (SIGSEGV) or hangs in `chatMigrateInit` when the database is re-created. Mitigations: + - the template database comes from the app's own startup database, so later resets run no migrations; + - a 5 min per-test watchdog; + - runner resume after a crash. + + Orchestrator `clearPackageData` is still not needed. +- **Speed, measured:** the reset went from 31.6 s to about 4 s; closing the activity from 45 s to 0.4 s, by releasing the Espresso-Intents stub first; bridge startup from 1–7 min to 1 s, by running it without Gradle. Waydroid now boots alongside the bridge start, so run setup takes 33 s with an unchanged app APK. A simple test now takes 15–20 s, against 65–90 s before. In the last full run (`run-20261014-164429`, 53/53, 16.5 min of instrumentation, about 19 s per test on average) the `reset` phase took 5.1 s median (3.7–7.7 s, n = 53, from logcat), because the template was still encrypted ("database is locked" on decryption; the `apiStopChat` fix is not yet run, plan 15.4 item 25). Final run 2026-10-14 (`run-20261014-214038`, 127 tests, 126 passed, 1 skipped, 18.5 min, about 8.8 s per test): the template is now decrypted, and the `reset` phase took 585 ms median (64 ms–3.2 s, n = 126). +- **Compose test clock:** `poll` must advance `mainClock`, because effect `delay()`s (mark-read 600 ms, alias 500 ms) run on the test clock. +- **Root actions:** `adb root` restarts `adbd` and kills a running `am instrument`. Use `wd.sh rootsh`, for example `kill -3 ` for thread dumps; `run-as` is blocked. + diff --git a/plans/ui-tests/2026-10-13-p1a-start-here.md b/plans/ui-tests/2026-10-13-p1a-start-here.md new file mode 100644 index 0000000000..5a8bbcb3b8 --- /dev/null +++ b/plans/ui-tests/2026-10-13-p1a-start-here.md @@ -0,0 +1,70 @@ +# Desktop UI tests — P1a: start here + +> **Historical (2026-10-14):** P1a was completed on 2026-10-13 (plan 15.1). This page is kept for history; for the harness as built see plan section 0.5, "As built (2026-10-14)", which also lists the design items not built yet. + +This page tells the implementer of P1a which parts of the plan to read and in what order to build. The plan is `plans/ui-tests/2026-10-10-desktop-ui-test-dsl-plan.md`; section numbers below refer to it. P1a's scope and exit criteria are in its row in §12. + +## Goal + +Get five real catalogue tests passing on the real desktop app, headless, with a real core and a CLI peer. Measure what the rest of the plan depends on. Change the plan where the measurements disagree with it. Then build P1b. + +The five tests: +- 01 `oneTimeLinkCliConnectsViaShortLink` +- 01 `addressCliConnectsAndRequestAppears` +- 03 `groupInvitation_tapRowShowsJoinAlert` +- 02 `sendText_enter_peerReceives` +- 04 `appearance_settingsPersistAcrossRestart` + +The first three are written out in §9.3. Run them on the Current CLI only. + +## Read (about 2 hours) + +| Read | Why | +|---|---| +| Summary, §1, §2 | What is being built and how the parts fit | +| §3.1, §3.2 | `Modifier.ui(key)`, `uiTagsEnabled` (debug/test only), and where a key must sit | +| §6 | Synchronization: the manual clock, `UiIdle` or its fallback, the hard timeout. Skip the end-of-test dialog drain | +| §7.1 and §7.1.1 | Gradle task, opt-ins, `forkEvery` (planned 1; as built 0 by default, `-PuiForkEvery`, plan 0.5.9), runner script | +| §7.2, §7.3 | `bootJvm`, `resetForTest`, `restartCore`, `PrefStores`, and what a reset must clear | +| §7.4 | Driver: input injection, waits, field reads | +| §8.1, §8.2, §8.3 | CLI peers, `uitest-servers` (SMP only for P1a), network namespace | +| §9.1–§9.3 | Screen objects, DSL, example tests | +| §10.1 | How catalogue names map to test classes | +| §12 (P1a row), §15 | Scope, exit criteria, open items | + +Skip for P1a: §3.3–§3.5, §4, §5, §7.5–§7.8, §9.4, §11. + +## Build order + +1. **Day 1 risk check.** Run `./gradlew -g .gradle/uitest-home --offline --no-daemon help` on the real project inside the namespace, after filling that Gradle home from outside (§7.1, §8.3). If the offline run fails, fix it before anything else, because everything later runs this way. +2. Add the `desktopUiTest` source set and task, with the opt-ins, `forkEvery = 1` (as built: default 0, plan 0.5.9) and per-JVM directories (§7.1). +3. Build `uitest-servers` with one SMP server (§8.2), plus the runner script and namespace (§7.1.1, §8.3). +4. Write `bootJvm` and the onboarding boot (§7.2). Set `user.home` before any app class loads, and repeat `System.load(libapp-lib)` + `initHS()`, because `initHaskell` is private to the desktop module. +5. Write the scene and driver: `runInternalSkikoComposeUiTest` with a `StandardTestDispatcher`, the clock-lead rule after input (not built: the poll advances a fixed 16 ms, plan 0.5.3), and desktop input wrapped in `runOnUiThread` (§6, §7.4). +6. Add the `UiIdle` hooks if approved, otherwise the fallback, plus the hard timeout (§6, §13). +7. Write `CliPeer` for the Current CLI with the peer flags and typing rules of §8.1 (as built: partly; no `--mute`, different typing constants, plan 0.5.5). +8. Add the keys and screen objects that the five tests touch (§3.1, §3.2, §9.1). Put each key on the same modifier chain as the clickable. +9. Write the five tests (§9.2, §9.3, and the catalogue rows). +10. Implement `resetForTest` and `restartCore` with `PrefStores` (§7.2, §7.3). Then run all five tests in one JVM and in fresh JVMs. +11. Take the measurements and run the exit check below. Write the numbers into §15 and adjust the budgets in §6 and §7.7. + +## Exit check + +- Each of the five tests passes 20 consecutive runs. +- These are measured on the real app and recorded in §15: + - boot and reset time per test; + - frame render cost; + - `captureToImage` time on the full scene; + - memory growth over 20 resets in one JVM; + - the offline Gradle run in the namespace. +- The scene runner choice and the DSL shape are confirmed, or the plan is changed, before P1b starts. + +## Traps the review found (each cost at least one round) + +- **Use the right code.** Branch off master in a new worktree. The catalogue's file:line references are pinned to `d72f68e6d` (`~/wt-uitest-ref`), not to the dirty `~/simplex-chat` checkout. +- **Merged nodes hide tags.** A tag on a child of a merged node is invisible in the default tree (§3.2). +- **The real CLI is not the Haskell test client.** A real profile gets the "Ask SimpleX Team" contact. `testView` lines are never printed. Peers need the flags in §8.1, and they must use name commands or `CliPeer.contactId`, not hard-coded contact ids. +- **Advancing the clock by 0** runs queued tasks but renders no frame (§6). +- **`hideAllAlerts` is not enough to close an alert.** It drops the composables but leaves continuations waiting. Close alerts with Escape (§6, "End of each test"). (Not built: the harness only calls `hideAllAlerts` at the next reset, plan 0.5.11.) +- **Line refs before the app.** A failing new test is first checked against its catalogue row and the code, because rows still contain errors at a small rate (§15). Fix the row in the same PR. +- **The root filesystem on this host is small.** Build under `/home`, not `/tmp`. diff --git a/plans/ui-tests/catalogue/00-legend.md b/plans/ui-tests/catalogue/00-legend.md new file mode 100644 index 0000000000..2fbbb6bc99 --- /dev/null +++ b/plans/ui-tests/catalogue/00-legend.md @@ -0,0 +1,208 @@ +# UI test catalogue 0: shared legend and rules + +Applies to catalogues 01–04. The plan (`20261010-desktop-ui-test-dsl-plan.md`, §10) points here. + +## Paths + +All paths are repo-relative: +- `C/` = `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/` +- `D/` = `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/` +- `H/` = `tests/ChatTests/`; other Haskell tests are given as `tests/.hs` +- Haskell core paths without a prefix (`Library/…`, `Store/…`, `Types/…`, `Messages.hs`, …) are under `src/Simplex/Chat/`; `Agent/…` and other simplexmq paths are in simplexmq at the commit pinned by `cabal.project` +- UI strings: `apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml` +- **Log** / "the command log" in any catalogue is the app's command log, `chatModel.terminalItems`: `sendCmd` adds `TerminalItem.cmd(obfuscated)` and `.resp` (`C/model/SimpleXAPI.kt:936-954`); commands sent with `log=false` (the subscription total :560, the servers summary :569, badge redemption :580) never appear there. `` and `` in logged commands are the same user id; JSON arguments are pretty-printed (`SimpleXAPI.kt:6546-6551`), so assertions match `"key": value` with a space +- **Pref** `` in catalogues 01–04 is the key in `settings.properties` (key strings at `C/model/SimpleXAPI.kt:390-530`, properties at :103-280); theme overrides live in `themes.yaml` instead; catalogue 05 writes `Pref = ` with the AppPreferences property name, since Android has no `settings.properties` + +Line numbers are those of master at `d72f68e6d` (2026-10-10), checked out at `~/wt-uitest-ref`. + +At P1 the catalogue moves into the repo at `apps/multiplatform/common/src/desktopTest/resources/uitest-catalog/`. + +## Canonical row format (parsed by the traceability check) + +- Every feature table has a column titled exactly **`Test case(s)`**. It contains only backticked test names separated by `, `. Nothing else goes in that cell. +- A test name is a valid Kotlin identifier, unique across all four catalogues. +- Test names in prose, notes or other columns are not counted. +- Every catalogue ends with a table titled `## Count` with columns `Section | Tests | Ignored`. `Tests` counts the names in that section's `Test case(s)` cells; `Ignored` counts the subset that must be `@Ignore` (see below). The last row is `| **Total** | **N** | **M** |`. +- Features that can't be tested in-process are rows too: their names go in the `Test case(s)` column like any other, and the `Needs` cell says `IGNORE: `; the reason must fall under a plan §11.3 bullet. The `@Ignore` string is the cell text after `IGNORE: `, verbatim, including any parenthetical. The traceability check requires a `@Test` method for every name; for `IGNORE` rows that method carries `@Ignore("")`. +- **Needs apply to every name in the row.** A row whose tests need different things is split, so that counts and JUnit categories derived from Needs are exact. +- **Needs cells contain only codes from the table below**, each optionally followed by `×N` (number of peers for `P`, of relays for `RELAY`, e.g. `P ×3`, `RELAY ×2`) and/or a short parenthetical detail (e.g. `FIXTURE (legacy address)`, `FD (wallpaper image)`). Setup states that are not harness capabilities (lock on, two users, four chats, a group) belong in the row's notes, not in Needs. +- Section headings carry the section's test count, e.g. `### B. Composing and sending text, links, mentions, commands (29 tests)`; the file header states the totals; nothing follows the `## Count` table. + +## Users and CLI output + +- The GUI user is **alice**, created with a display name only (no full name). CLI peers are created with `--user-display-name ` and also have no full name. +- So CLI lines never contain a full name: `alice: contact is connected`, `alice wants to connect to you!`, not `alice (Alice): …`. +- When a Haskell test is cited, its expected lines are rewritten for these roles and names. The GUI user's own output is asserted on the GUI, never as a CLI line. + +## Needs codes (one shared set) + +| Code | Meaning | +|---|---| +| `P` | CLI peer(s), the current build unless the row names a stable one | +| `S` | also run with the stable-release CLI peer. These tests live in their class base's separate `open class CompatTest` (class map), whose subclass `CompatStableTest` overrides the peer version; never combined with `RELAY` (channels need current-build peers) | +| `SMP` | local SMP server (always available, so never needed in a cell; stopping or starting it is `SRVCTL`) | +| `SMP2` | a second local SMP server (private routing, manual server add, a peer on another server) | +| `XFTP` | informational: the row transfers files through the local XFTP server. The server is always available (like `SMP`), so the code derives no category and requires nothing; it only marks file rows | +| `XFTP2` | a second local XFTP server (unknown-relay approval) | +| `RELAY` | chat relay CLI process(es) | +| `NAMES` | SMP server in names mode + mock name registry | +| `HTTP` | local HTTP server (link previews, `http://127.0.0.1:`) | +| `SOCKS` | in-JVM SOCKS5 stub | +| `SRVCTL` | stop/start a local server mid-test | +| `PEERCTL` | stop/start a CLI peer or relay, or restore its database copy | +| `RESTART` | core restart inside the test | +| `FIXTURE` | database fixture (named in the row): prepared before boot via `app(fixture = …)`, or between `restart()` steps for data the test produced itself (then also `RESTART`) | +| `ONBOARD` | the test boots with no user (`app(name, onboarded = false)`); tests that reach onboarding later need no `ONBOARD`: through a restart they have `RESTART`, and in-app (e.g. deleting the database, which shows onboarding at once) they need neither | +| `FD` | file-dialog completion, or asserting that no file dialog is awaiting (`UiFiles`, plan §7.6) | +| `CB` | in-memory clipboard (`LocalClipboardManager`/`LocalClipboard`) | +| `IMGCB` | the approved Ctrl+V clipboard seam (plan §11.2): Ctrl+V in the compose field when the clipboard holds plain text or an image (a file list needs only `CB`) | +| `URI` | `LocalUriHandler` stub (links, browser, calls) | +| `NTF` | recording notification manager | +| `TOAST` | toast assertions | +| `DT` | developer tools on | +| `REC` | recorder seam (plan §11.2) | +| `AV` | audio/video player seam (plan §11.2) | +| `MCAST` | multicast on loopback (plan §8.3); runs in the non-parallel `Multicast` category | +| `SLOW` | 30 s or more (timed-message deletion, TTL expiry, large groups) | +| `REALTIME` | needs the real display clock: `uiTest(realTimes = true)`, no screenshots (plan §7.7) | +| `—` | no other code applies: no peer and no harness capability | +| `IGNORE: ` | cannot run in-process; `@Ignore` with this reason | + +## Catalogue 05 (Android-only) + +`05-android-only.md` covers UI that exists only on Android or behaves materially differently there (added 2026-10-12). It is for the Android harness (`plans/ui-tests/2026-10-12-android-ui-test-automation.md`, Waydroid), not the desktop run: its tests are outside the desktop counts, class map and traceability check above, and the count tools read 01–04 only. It defines its own extra Needs codes (`PERM`, `SYSUI`, `SYSNTF`, `INTENTS`, `INTENT`, `BG`, `BACK`, `IME`, `VIEW`, `SHELL`, `ROT`) and IGNORE reasons at its top; rows of 01–04 carry over to Android unchanged unless `05-android-carryover.md` lists them (REPLACED by a 05 test, DESKTOP-ONLY, or ADAPT with the Android change; 357 of 1,177 listed, 2026-10-13). + +## Ownership of overlapping features + +A feature is tested once, in the catalogue that owns it. Duplicates are deleted from the other catalogue. Rows that are unique stay where they are. + +| Feature | Owner | The other catalogue keeps only | +|---|---|---| +| User picker, chat profiles list, add/activate/search/hide/unhide/mute/delete profile, your-profile editing, user chat preferences | 01 (§5, §14) | 04: settings-root rows, colour-mode switch, Esc keys, lock-protected profile actions | +| Onboarding incl. passphrase setup and the onboarding "Link a mobile" screen (QR, device name, back) | 01 (§1) | — | +| What's New (auto-show, pages, read-more, from settings) | 01 (§2) | 04 Help: none | +| SimpleX Lock notice, delivery-receipts startup prompt, app-update channel notice | 04 | 01: none | +| Remote desktop pairing, use, disconnect, stored pairing, delete, port, multicast — including pairing from onboarding | 04 (§K) | 01: the onboarding "Link a mobile" screen UI only | +| Message draft setting ("Message draft" in More privacy): turning it off | 04 (privacy) | 02 §S: draft behaviour with the setting on (default) | +| Sent-via-proxy setting ("Show message status") and the proxy icon | 04 (network) | 02: none | +| "Remove link tracking" setting and its effect while typing | 04 (privacy) | 02: none | +| "Show encryption" setting and the lock icon on items | 04 (privacy) | 02: none | +| Group invitation item in the direct chat with the host (tap to join, incognito label, sent item), and the join-time "Invitation expired!" alert | 03 (§8) | 02 §U: the expired invitation item label (an `IGNORE` row: never produced by the core) | +| Group event items (merged/collapsed events, expand/hide, role changes) | 03 (§16) | 02: selecting a merged range (§K) and deleting it (§U) | +| Welcome message received as the first item | 03 (§4) | — | +| Group disappearing messages (preference and expiry) | 03 (§5) | 02 §D: direct-chat timed messages | +| Group toolbar ⋮ entries ("Invite members", "Group link") | 02 (§A) | 03: entry points on the group info screen | +| "Send link previews" setting | 04 (privacy): turning it off | 02 §B: previews with the setting on (default), the first-time alert, fetch, cancel, received cards | +| "Auto-accept images" setting | 04 (privacy): turning it off | 02 §G: automatic receipt with the setting on (default) | +| Calls: toolbar call buttons, call items, incoming call alerts, the WebRTC `IGNORE` row | 02 | 01 §13: the contact-info call buttons and their "prohibited"/"allow" alerts | +| Chat-list rows and row menus for every chat type (rendering, flags, favorite, tags, mute modes) | 01 (§3) | 03: group-specific actions (join/delete invitation, leave, delete, archive reports) and the report/support/pending flags asserted in their own sections | +| A contact deleted by the peer (row label, disabled compose and its alert) | 01 (§15) | — | +| Private notes clear | 01 (§3, notes menu) | — | +| Address and business-address settings | 01 (§11) | 03: business-chat flows (customer and business sides, team members) | +| Contact, invitation, address, bot, name, signature, URI and link-card connection plans; prepared direct chats | 01 (§8, §9) | — | +| Group, channel, relay-address and business connection plans; joining groups and channels; prepared groups and business chats | 03 (§8, §19, §20) | 01: search filtering on a pasted known group link (§3), the channel button of a SimpleX-name plan (§8), pending-connection info of a group link (§10) | +| Mentions while composing (picker, filter, limit, received highlight) | 02 (§B) | 03 §15: mentions-only mute notification, blocked member's mention ignored | +| Moderation (single, full delete, multiple, received moderation, not offered) | 03 (§14) | 02: rendering of blocked-by-admin and collapsed marked-deleted items | +| Reports: member side (compose, reasons, sent alert, not offered, preference off, not visible to other members) | 02 (§O) | — | +| Reports: moderator side (banner, reports view, archive, delete, multi-archive, jump to reported message, archive all from chat list) | 03 (§13) | — | +| Member contacts: create, accept, reject, incognito | 03 (§11) | 02: the "requested connection" event item; 01: none | +| Clear chat and chat TTL from contact info | 01 (§13) | — | +| Clear chat from group info | 03 (§17) | — | +| Chat toolbar ⋮ menu (incl. mute) | 02 (§A) | 01: none | +| Contact preferences | 01 (§14) | 02: feature items and offers in the chat, the compose "Allow voice messages?" alert | +| Group preferences, their effects in group chats (incl. reactions or voice off) and the resulting feature items | 03 (§5) | 02: the disabled attach button when group files are off (§F), reports with the preference off (§O) | + +## Class map + +Each sub-area (a `###` section) is one test class, or several consecutive classes when it has more than 30 tests. A class holds the names from its first test up to the next class's first test, in catalogue order. Packages: catalogue 01 → `chat.simplex.app.ui.main`, 02 → `chat.simplex.app.ui.chat`, 03 → `chat.simplex.app.ui.groups`, 04 → `chat.simplex.app.ui.settings`. Within a class's range, names whose row has `S` go to `open class CompatTest` (plus `@Category(StableCompat::class) class CompatStableTest : CompatTest()`), the others to `Test`. The traceability check reads this table; regenerate it with the counts after any change. + +| Cat | § | Sub-area | First test | Classes | Tests | Ignored | +|---|---|---|---|---|---|---| +| 01 | 1 | Onboarding | `onboardingWelcomeShowsDesktopActions` | `OnboardingTest` | 24 | 0 | +| 01 | 2 | What's New | `whatsNewFromSettings` | `WhatsNewTest` | 6 | 0 | +| 01 | 3 | Chat list | `chatListTitleAndProfileButton` | `ChatListToolbarSearchTest` | 18 | 0 | +| 01 | 3 | Chat list | `unreadFilterToggle` | `ChatListFiltersListsTest` | 25 | 0 | +| 01 | 3 | Chat list | `previewShowsLastTextMessage` | `ChatListRowsTest` | 22 | 0 | +| 01 | 3 | Chat list | `contactMenuMarkRead` | `ChatListMenusPendingTest` | 15 | 0 | +| 01 | 3 | Chat list | `contactRequestMenuAccept` | `ChatListRequestsCardsTest`, `ChatListRequestsCardsCompatTest`, `ChatListRequestsCardsCompatStableTest` | 26 | 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`, `AddressCompatTest`, `AddressCompatStableTest` | 23 | 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_enter_peerReceives` | `ComposeTest`, `ComposeCompatTest`, `ComposeCompatStableTest` | 29 | 0 | +| 02 | C | Live messages | `liveMessage_menuStart_alertConfirm_peerSeesStarted` | `LiveMessageTest` | 10 | 0 | +| 02 | D | Timed / disappearing messages | `timed_send30s_bothSidesDelete` | `TimedMessageTest` | 6 | 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 | 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 | 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 | 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 | +| 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 | 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 | 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 | + +## Count rules + +- After any change, recount with `tools/recount.py ` (it rewrites the section headings and the `## Count` table from the `Test case(s)` cells), then update the file header and regenerate the class map (`tools/classmap.py table`). +- A class holds at most 30 tests (a sub-area with more is split, class map); the largest class now has 29, the bound the plan quotes for per-JVM leaks (§7.3). +- The plan's numbers derived from the catalogue (Summary, §7.7 screenshot count, §10 table, §10.1, §10.2, §11.1 NAMES/RELAY, §11.2, §12, §14) are regenerated from the four `## Count` tables, the class map and the Needs counts (`tools/catcheck.py`, `tools/classmap.py`, `tools/needs.py`). diff --git a/plans/ui-tests/catalogue/01-onboarding-chatlist-connect-profiles.md b/plans/ui-tests/catalogue/01-onboarding-chatlist-connect-profiles.md new file mode 100644 index 0000000000..70981bddf5 --- /dev/null +++ b/plans/ui-tests/catalogue/01-onboarding-chatlist-connect-profiles.md @@ -0,0 +1,311 @@ +# 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: **352 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. +2. **Pre-set prefs** for onboarded tests unless the test is about them (full list in plan §11.1; onboarding boots keep defaults). Three notices are checked once at first composition, so a test about one of them changes the pref and then calls `restart()`: `laNoticeShown` (the "SimpleX Lock" alert appears once there are more than 3 chats, `C/App.kt:94-103`), `appUpdateNoticeShown` (the app-update channel notice, `apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt:79-87`) and `whatsNewVersion` (What's New, `ChatListView.kt:219-228`). The others take effect without a restart: `oneHandUICardShown`, `addressCreationCardShown`, `getStakeBannerDismissed` (crowdfunding is always available on desktop, `C/views/onboarding/WhatsNewView.kt:1073`), `supporterBannerShown` and `showHiddenProfilesNotice` are read through `.state` (`ChatListView.kt:950-955`, `UserProfilesView.kt:191`); `showMuteProfileAlert`, `showDeleteConversationNotice` and `showDeleteContactNotice` are read when the action runs (`UserProfilesView.kt:119`, `ChatInfoView.kt:259,337`); `privacyLinkPreviewsShowAlert` is read when a typed link is first detected (`ComposeView.kt:476`); `privacyDeliveryReceiptsSet` is read only in `startChat` (`SimpleXAPI.kt:679`; catalogue 04 owns its tests). +3. **Local servers only.** `acceptConditions` → `setServerOperators` (`C/views/onboarding/ChooseServerOperators.kt:293-311`) enables the operators selected during onboarding (`enabledOperators`, :331-358), whose preset servers are unreachable in the loopback namespace; onboarded tests get the local servers in `resetForTest`; onboarding tests call `TestServers.configure()` (`configureServers(smp1, xftp1)`) after the chat list appears and before anything connects (plan §8.3). No core change is needed. +4. **Async waits.** Most actions run through `withBGApi`; every lookup and assertion polls with the manual clock (plan §6). Known delays: the What's New modal waits 1 s (`ChatListView.kt:225`), name search is debounced 300 ms (`ChatListView.kt:1143`), and the subscription indicator polls every 1 s (`ChatListView.kt:686`). +5. **Controls with no text or contentDescription.** Tests find these by their `Ui` keys (plan §3): + - unread filter toggle (`ChatListView.kt:758`, `NewChatSheet.kt:582`) + - operator logo cards (`ChooseServerOperators.kt:234`) + - copy/share icons in `LinkTextView` (`NewChatView.kt:751-762`) + - toolbar call button (`ChatView.kt:1291`) + - one-hand card close (`ChatListView.kt:168`) + - user picker avatars +6. **Onboarding goes through the passphrase step.** A fresh database has a random passphrase (`initialRandomDBPassphrase`), so "Link a mobile" goes to "Setup database passphrase" first (`D/views/onboarding/SimpleXInfo.desktop.kt:19`), and "Create profile" goes there right after the profile is created (`C/views/WelcomeView.kt:400`). Tests reaching later steps pass it (e.g. "Use random passphrase"). +7. **QR codes can't be read in the test.** Assert link text through the clipboard stub, or read `chatModel.showingInvitation`/`userAddress` directly. +8. **Context menus** open on right-click (`D/views/chatlist/ChatListNavLinkView.desktop.kt:39-41`): use the driver's `rightClick()` or `longClick()` (plan §7.4). + +--- + +### 1. Onboarding (24 tests) + +| Area | Feature | Entry path (labels) | Main file:line | Test case(s) | Verification (GUI / CLI / Haskell) | Needs | Testability notes | +|---|---|---|---|---|---|---|---| +| Onboarding | Welcome screen (desktop) | First launch, onboarding stage `Step1_SimpleXInfo` | `C/App.kt:275-292`; `C/views/onboarding/SimpleXInfo.kt:67`; `D/views/onboarding/SimpleXInfo.desktop.kt:16-23` | `onboardingWelcomeShowsDesktopActions`, `onboardingWhySimplexIsBuiltOpensHowItWorks` | Shows "Link a mobile", "Create your profile" and "Why SimpleX is built."; the last opens HowItWorks, headed "You were born without an account." (`HowItWorks.kt:31`, `why_built_heading`), which the welcome screen does not show | ONBOARD | Desktop stage order differs from Android: "Link a mobile" goes to the passphrase step when `initialRandomDBPassphrase` is set; on desktop with no user HowItWorks repeats the "Link a mobile"/"Create your profile" buttons (`HowItWorks.kt:46` → `SimpleXInfo.desktop.kt:16-21`), so their keys match two nodes while it is open | +| Onboarding | Create first profile | "Create your profile" → "Your profile" → field "Enter profile name…" → "Create profile" | `C/views/WelcomeView.kt:313,389,415,480,485` | `onboardingCreateProfileValidName`, `onboardingCreateProfileButtonDisabledForEmptyName`, `onboardingCreateProfileInvalidNameShowsCorrectionAlert`, `onboardingCreateProfileBackReturnsToWelcome` | Button is disabled for an empty or invalid name. An invalid name shows the info icon, then the "Invalid name!" / "Correct name to %s?" alert (e.g. `a_1` → "Correct name to a-1?"); confirming replaces the name. Success moves to "Setup database passphrase" (fresh database, setup item 6). Back (`WelcomeView.kt:227`) returns to the welcome screen. Name rule: core `mkValidName` (`src/Simplex/Chat/Library/Commands.hs:6703`; Haskell `tests/ValidNames.hs:60`: `alice_1` → `alice-1`) | ONBOARD | Name validity comes from core `chatValidName` (FFI over `mkValidName`); a `_N` suffix is invalid, so use `a_1` for the invalid case and `a1` or `a-1` for the valid one | +| Onboarding | Migrate entry | Create profile screen → "Migrate from another device" | `C/views/WelcomeView.kt:201` | `onboardingMigrateFromAnotherDeviceOpensMigrationScreen` | Opens `MigrateToDeviceView` ("Migrate here", `MigrateToDevice.kt:171`) in the `PasteOrScanLink` state, showing "Paste archive link" and "Or import archive file" (`:215-221`) | ONBOARD | Entry only; the full migration is another area | +| Onboarding | DB passphrase setup | After profile → "Setup database passphrase", fields "New passphrase…" / "Confirm new passphrase…", "Set database passphrase" → "Encrypt database?" / "Encrypt", or "Use random passphrase" → "OK" | `C/views/onboarding/SetupDatabasePassphrase.kt:26,96,191,202,229`; `C/views/database/DatabaseEncryptionView.kt:220` | `onboardingSetDatabasePassphrase`, `onboardingSetPassphraseMismatchDisablesButton`, `onboardingUseRandomPassphraseAlert` | Mismatched passphrases keep the button disabled; the button is also disabled when the new passphrase equals the current one or contains whitespace or non-ASCII characters (`SetupDatabasePassphrase.kt:118-123`, `validKey`). "Set database passphrase" shows the "Encrypt database?" alert ("Database will be encrypted."); "Encrypt" sets `storeDBPassphrase=false`, encrypts, shows the "Database encrypted!" alert (`DatabaseEncryptionView.kt:475`, over the next screen) and moves to "Your network" (or "Link a mobile" if no user). Random shows the "Use random passphrase" alert ("Random passphrase is stored in settings as plaintext…"); "OK" moves on the same way. Haskell: `testDatabaseEncryption` (H/Direct.hs:1589) | ONBOARD | — | +| Onboarding | DB passphrase setup | After profile → "Setup database passphrase", fields "New passphrase…" / "Confirm new passphrase…", "Set database passphrase" → "Encrypt database?" / "Encrypt" | `C/views/onboarding/SetupDatabasePassphrase.kt:26,52,96,191,202,229` | `onboardingPassphrasePersistsAcrossRestart` | After "Encrypt" (and its "Database encrypted!" alert), `storeDBPassphrase` is false and no passphrase is stored in settings (`EncryptedDBPassphrase` absent); after the restart the passphrase prompt (`DatabaseErrorView`, "Encrypted database" / "Database passphrase is required to open chat.", `DatabaseErrorView.kt:96-97`) is shown. Haskell: `testDatabaseEncryption` | ONBOARD, RESTART | Restarts the core with the harness restart (plan §11.1). Only "Encrypt" sets `storeDBPassphrase=false` (`SetupDatabasePassphrase.kt:52`); a random passphrase is stored in settings, so the restart would show no prompt. Entering the passphrase on the startup screen is tested in 04 §G `dbError_enterPassphraseOnStartup_open`; this test stops at the prompt | +| Onboarding | Your network | "Your network" → "Setup routers" button; "Continue" | `C/views/onboarding/YourNetwork.kt:42,136,173` | `onboardingYourNetworkShowsSetupRouters`, `onboardingContinueGoesToConditions` | Title and subtitle shown; "Continue" goes to "Network commitments" | ONBOARD | Reached after the passphrase step (setup item 6). The notifications button exists only on mobile (`YourNetwork.kt:105`) | +| Onboarding | Server operators | "Setup routers" → "Server operators" → operator cards, "How it helps privacy", "OK" | `ChooseServerOperators.kt:178,234,281,362` | `onboardingChooseOperatorsToggle`, `onboardingChooseOperatorsCannotDeselectAll`, `onboardingHowItHelpsPrivacyInfo` | Tapping a card toggles its check circle. With none selected, "OK" and close are disabled. Info screen title "Network operators". Haskell: `testOperators` (Direct.hs) | ONBOARD | Reached after the passphrase step. Cards are logo images with no text; found by their `Ui` keys | +| Onboarding | Conditions | "Network commitments" → "Privacy policy and conditions of use." / "Accept" | `ChooseServerOperators.kt:42,134,293,314` | `onboardingAcceptConditionsCompletesOnboarding`, `onboardingConditionsLinkOpensConditionsText` | "Accept" sets the stage to `OnboardingComplete` and shows the chat list "Chats". The link opens "Conditions of use" (`SimpleConditionsView`, `NetworkAndServers.kt:836`), whose "Accept" also leads to "Chats" | ONBOARD | Accepting enables the operators selected in onboarding (`ChooseServerOperators.kt:299-305`, `enabledOperators` :331-358), whose servers are unreachable in the namespace; tests that connect afterwards call `TestServers.configure()` (setup item 3) | +| Onboarding | Conditions with no operator enabled | "Network commitments" | `ChooseServerOperators.kt:48-51,293` | `onboardingAcceptButtonDisabledWithNoOperators` | With no operator enabled in the core, the initial selection is empty and "Accept" is disabled | ONBOARD, FIXTURE (all operators disabled) | Deselecting in the UI falls back to the operators enabled in the core (`ChooseServerOperators.kt:48-51`), so the state needs a fixture | +| Onboarding | Notifications mode | (mobile only) | `C/views/onboarding/SetNotificationsMode.kt:25` | `onboardingNoNotificationsStepOnDesktop` | On "Your network" the desktop screen shows "Setup routers" and no "Setup notifications" (`YourNetwork.kt:58-59,181`; the Android-only button at `:105-109,214` is the one entry to "Private notifications") | ONBOARD | Assert it's absent | +| Onboarding | First contact after onboarding | Complete onboarding → New chat → 1-time link | (see §7) | `onboardingThenConnectToCliPeerViaLocalServers` | After onboarding (passphrase step included) the test calls `TestServers.configure()`, creates a 1-time link; CLI `/c ` gives "confirmation sent!" then "alice: contact is connected". Haskell: `testAddContact` (H/Direct.hs:191) | ONBOARD, P | Smoke test of the local-server setup | +| Onboarding | Link a mobile (onboarding) | Welcome → "Link a mobile" → "Link a mobile" screen: "This device name", "Discoverable via local network", QR code, "Share link" | `C/views/onboarding/LinkAMobileView.kt:24,52`; `C/views/remote/ConnectMobileView.kt:269` | `onboardingLinkAMobileShowsQrAndDeviceName` | After the passphrase step (`SimpleXInfo.desktop.kt:19`) the screen shows the device name field, the discoverable toggle and the QR code; with developer tools on, "Share link" puts the invitation on the clipboard stub. Pairing with a CLI acting as the mobile, including from onboarding, is tested in catalogue 04 §K | ONBOARD, CB, DT | "Share link" is shown only with developer tools on (`C/views/remote/ConnectMobileView.kt:207-213`); remote desktop works over loopback (plan §8.3); the test turns `offerRemoteMulticast` off before opening the screen so that `startRemoteHost` (`ConnectMobileView.kt:282-287`) does not announce over multicast (otherwise Needs would include `MCAST`) | +| Onboarding | Link a mobile (onboarding) | Welcome → "Link a mobile" → "Setup database passphrase" ("Use random passphrase" → "OK") → "Link a mobile" screen → Back | `C/views/onboarding/LinkAMobileView.kt:24,52`; `C/views/remote/ConnectMobileView.kt:269` | `onboardingLinkAMobileBackReturnsToWelcome` | Back returns to the welcome screen | ONBOARD | – | +| Onboarding | Only hidden users exist | Create profile while only hidden users exist | `WelcomeView.kt:399-410` | `onboardingCreateProfileWhenOnlyHiddenUsersExist` | Continues like a fresh install: with no visible user (`WelcomeView.kt:398-404`) onboarding moves on to "Setup database passphrase" or "Your network", not straight to `OnboardingComplete` | ONBOARD, FIXTURE (only hidden users, none active) | Fixture: a database whose only users are hidden and none is active (`active_user = 0`), so startup takes the no-user path (`C/platform/Core.kt:153-166`) | + +### 2. What's New (6 tests) + +| Area | Feature | Entry path | Main file:line | Test case(s) | Verification | Needs | Notes | +|---|---|---|---|---|---|---|---| +| 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` | `whatsNewFromSettings` | Title "New in v7.0.1" (the last entry, `WhatsNewView.kt:68,176,946`; `isInUs()` is true on desktop, where `androidPlayStoreCountry` stays null, :1083-1086); opens from Settings → "Help & support" → "What's new"; no "OK" button there (`WhatsNewView.kt:218`), closed with back | — | – | +| Startup | What's New after an upgrade | Automatic 1 s after the chat list when the stored `whatsNewVersion` differs from the last version (including unset) | `C/views/onboarding/WhatsNewView.kt:218,1030`; `ChatListView.kt:219-228` | `whatsNewShownAfterVersionUpgrade`, `whatsNewOkCloses` | Shown automatically 1 s after the chat list when `whatsNewVersion` is older; "OK" closes it | RESTART | The test sets an older `whatsNewVersion`, then restarts so the chat list is composed again | +| 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` | `whatsNewPaginationPrevNext` | Opens on "New in v7.0.1" (the last entry: v7.1 is commented out, `WhatsNewView.kt:973-985`; desktop has no Play Store country, so `isInUs()` is true, `:946,1083-1086`; opens on the last index, `:69`); the previous link "v6.5" (`:913`) shows "New in v6.5", and its next link "v7.0.1" shows "New in v7.0.1" again (`:145,150,160`; no next link on the last entry); on "New in v7.0.1" "Read more" shows the "Open external link?" alert (`WhatsNewView.kt:261`, `C/views/helpers/Utils.kt:618-622`), and "Open" records `https://simplex.chat/blog/20260819-simplex-chat-crowdfunding.html` in the URI stub (`:947`; v7.1 has no post, `:976`) | URI | – | +| 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 (106 tests) + +| Area | Feature | Entry path | Main file:line | Test case(s) | Verification | Needs | Notes | +|---|---|---|---|---|---|---|---| +| Toolbar | Title, profile button | Main screen, title "Chats" | `ChatListView.kt:521-665,704` | `chatListTitleAndProfileButton`, `chatListScrollToTopOnTitleClick` | Title "Chats" (`your_chats`, `ChatListView.kt:634`); clicking the profile image opens the user picker (the picker sheet, tagged only while it is visible: Android keeps the picker composed off-screen, `UserPicker.android.kt:143-162`, and on desktop it is the `AnimatedVisibility` content, `UserPicker.desktop.kt:91-108`), whose first option row reads "Create SimpleX address" (`UserPicker.kt:222`); with enough chats to scroll (e.g. 30) scrolled to the last one, clicking the title brings the first chat row back into view (the title is clickable only while the list is off the top, `canScrollToZero`, `ChatListView.kt:611,660`) | — | – | +| Toolbar | Unread dot for other users | Second user gets messages | `ChatListView.kt:621-627,732` | `chatListUnreadDotOnProfileButtonForOtherUsers` | A badge appears on the profile button once bob has messaged alice's second, non-active user (its unread counter goes up on arrival, `C/model/ChatModel.kt:1033`); after switching to that user, opening bob's chat and switching back, the badge is gone | P | Badge is an empty Text (`ChatListView.kt:732-744`) inside the profile IconButton (:706,713-714); hidden and active users are excluded from the dot (`ChatListView.kt:621-624`) | +| Toolbar | Chat stopped | Stop chat (Settings › database) | `ChatListView.kt:545-559` | `chatListStoppedChatIndicator` | Red icon "Chat is stopped"; clicking it shows "You can start chat via app Settings / Database or by restarting the app." | — | – | +| Toolbar | Subscription indicator | Icon next to "Chats" | `ChatListView.kt:668`; `ServersSummaryView.kt:120,349` | `subscriptionIndicatorConnected`, `subscriptionIndicatorPercentageToggle` | With "Show percentage" on (Servers info, `ServersSummaryView.kt:349`; off by default, `SimpleXAPI.kt:155`), the text next to the icon goes from "%" (no subscriptions) to "100%" within 2 s after the 1-time link is created and the new-chat modal is closed with "Keep" in the "Keep unused invitation?" alert (`NewChatView.kt:76-97`; "Delete" removes the queue) — or after an invitation is created through the API with no modal open (`ServersSummaryView.kt:121,141`; the colour alone can't tell connected from a fresh install, :87-104; the 1 s poll skips while a start or fullscreen modal is open, `ChatListView.kt:686-690`); the toggle shows and hides the text. The toggle is "Show percentage" in Servers info (opened from the indicator, `ChatListView.kt:640-654`), "Messages" tab, "Message reception" section (`ServersSummaryView.kt:325-352`); close Servers info to see the toolbar text; the pref key is `ShowSubscriptionPercentage` (`SimpleXAPI.kt:449`) | — | 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 | 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 | 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 | +| Filters | Preset tags | "Favorites", "Contacts", "Groups", "Channels", "Businesses", "Notes", "Reports" | `ChatListView.kt:1281-1304,1410,1579-1617` | `presetFilterFavorites`, `presetFilterContacts`, `presetFilterGroups`, `presetFilterBusinesses`, `presetFilterNotes`, `presetFilterReports` | Presets show only when at least two kinds other than Reports have chats (`ChatListView.kt:1293`); they are chips when those kinds plus Reports plus user lists number at most 3, otherwise a menu (:1294-1303), with Reports always a chip (:1619-1622). Selecting one leaves only that kind in the list (`presetTagMatchesChat`, :1579-1606), so each test also has a chat of another kind that disappears: "Favorites" only the starred chat; "Contacts" contacts, requests, pending connections and business-customer chats, not a group; "Groups" a group, not a business chat or channel; "Businesses" only the business chat; "Notes" only the notes folder; "Reports" (always expanded) only the group with a report. Haskell: `testFilterFavorite`; `testBusinessAddress`; `testGroupMemberReports` (Groups.hs) | P | – | +| Filters | Preset tags | "Favorites", "Contacts", "Groups", "Channels", "Businesses", "Notes", "Reports" | `ChatListView.kt:1281-1304,1410,1579-1617` | `presetFilterChannels` | "Channels" filter lists channels the user joined or owns | P, RELAY | A channel needs a chat relay | +| Filters | Collapsed presets | More than 3 tags in total → menu icon "All" | `ChatListView.kt:1293-1303,1461-1517` | `presetFiltersCollapsedMenuWhenMany`, `presetFilterAllResets`, `presetTagsHiddenWhenOnlyOneKind` | With at least two kinds that can collapse (every preset except Reports, `ChatListView.kt:1284,1619-1622`), the presets are separate chips while kinds + Reports + user lists ≤ 3 (:1293-1297) and otherwise a menu whose icon reads "All" or the selected preset (:1477-1491); the menu lists the collapsible kinds that have chats (:1510-1514) and an "All" item only while a filter or search text is active (:1496), which clears both (:1501-1505); with fewer than two collapsible kinds no presets are shown | P | The notes folder counts as the Notes kind and the "Ask SimpleX Team" card is not a contact for the filters (`ChatListView.kt:1584`), so "a single kind" is the fresh list with notes only; one contact makes two kinds | +| Lists | Create list | Tags row "Add list" / "+" → editor field "List name...", emoji, "Save list" | `C/views/chatlist/TagListView.kt:173,196`; `D/.../TagListView.desktop.kt:25` | `createListFromAddList`, `createListWithEmoji`, `createListDuplicateNameError` | `createListFromAddList`: a new chip with the list's name appears; `createListWithEmoji`: the chip shows the chosen emoji in place of the label icon (`ChatListView.kt:1336-1344`) and the tag returned by `/_create tag` (`CRChatTags`, `Library/Commands.hs:763-765`) carries the emoji; `createListDuplicateNameError`: with an existing list's name "Save list" turns disabled (`isDuplicateEmojiOrName`, TagListView.kt:259-264); the message "List name and emoji should be different for all lists." is always in the semantics tree and only changes colour (transparent otherwise, TagListView.kt:279, 287), so it is checked in the screenshot, not by semantics. Haskell: `testDeleteUserChatTags` (Direct.hs:2304, `/_create tag`) | — | Emoji input is a single-emoji text field on desktop (`D/views/chatlist/TagListView.desktop.kt:33-72`) and an `EmojiPickerView` AndroidView on Android (`TagListView.android.kt:23-51,54-81`), so `createListWithEmoji` is desktop-only; "Save list" when disabled drops its clickable without Disabled semantics (`C/views/helpers/Section.kt:212-217`), so the tests check that a click does nothing | +| Lists | Assign | Row menu "Add to list" / "Change list" | `ChatListNavLinkView.kt:390`; `TagListView.kt:47,402` | `addChatToListFromContextMenu`, `changeChatListFromContextMenu`, `removeChatFromList` | With no lists it opens the editor (button "Add to list"), which creates the list and assigns the chat; otherwise a list sheet with a check mark on the chat's list; tapping another list moves the chat to it (tags replaced, `TagListView.kt:402-411`), tapping the checked list removes it; the sheet closes after each tap. The item exists only for contacts with an active connection (`ChatListNavLinkView.kt:288-298`) and for groups past the invitation stage (:312-337), not for notes, requests, pending connections or group invitations; the check mark follows `chatInfo.chatTags` (`TagListView.kt:52,109,151-153`) | P | – | +| Lists | Filter by list | Tap a list chip | `ChatListView.kt:1306-1382,1243` | `filterByUserList`, `emptyListShowsNoChatsInList`, `listUnreadDot` | Filters; empty list shows "No chats in list %s."; " ●" when the list has unread chats; tapping the active chip again clears the filter (:1320-1321); the dot comes from `unreadTags` (:1348; `ChatModel.kt:1037-1051`) | P | – | +| Lists | Edit, delete, reorder | Right-click (desktop) or long-press (Android) chip → "Edit" / "Delete" / "Change order" (`ChatListView.kt:1326,1331`) | `TagListView.kt:297-353,478` | `editListNameAndEmoji`, `deleteListConfirm`, `reorderListsDrag` | `editListNameAndEmoji`: "Edit" opens the editor with the list's emoji and name; "Save list" stays disabled until one of them changes (`TagListView.kt:259-262`), and after saving the chip shows the new emoji and name (`apiUpdateChatTag`, :231-239); `deleteListConfirm`: "Delete list?" / "All chats will be removed from the list %s, and the list deleted" / "Confirm" removes the chip, and its chats stay in the chat list (dialog at `TagListView.kt:478-500`, deletion at :442-473); `reorderListsDrag`: "Change order" opens the lists in reorder mode, and after a drag the chips follow the new order (`apiReorderChatTags`, :54-70) | — | Emoji editing is desktop-only (see "Create list"), so `editListNameAndEmoji` is desktop-only; reordering is `detectDragGesturesAfterLongPress` (`C/views/helpers/DragAndDrop.kt:144-146`) with no semantics action, so `reorderListsDrag` can't be driven without coordinates | +| Lists | Per-user lists | Switch user | `C/model/SimpleXAPI.kt:775-776`; `src/Simplex/Chat/Store/Shared.hs:919-928` (tags selected by `user_id`) | `listsArePerUser` | Lists differ per profile: a new profile starts with none and "Add list"; switching back restores the first profile's lists | — | – | +| Rows | Message previews | Chat list rows | `ChatPreviewView.kt:41,226-305,308-370` | `previewShowsLastTextMessage`, `previewShowsDraft`, `previewLink`, `previewDeletedMessage`, `previewContactCardMessage` | `previewShowsLastTextMessage`: the last item's text, received or sent; `previewShowsDraft`: after alice types in bob's chat and switches to another chat (`ComposeView.kt:1381-1420`), the row shows the draft text after the edit icon (inline, no content description, `ChatPreviewView.kt:123-127`) in place of the last message (`:229-248`); `previewLink`: a link message (CLI `/_send @ json` with a `link` content and preview image) shows the link text and a thumbnail button that opens the link alert (`:318-330`); `previewDeletedMessage`: a message deleted by bob reads "marked deleted" (`:253`); `previewContactCardMessage`: bob's `/share address @alice` reads his name and, on a second line, his bio (`/p bob `; `:254-258`) | P | – | +| Rows | Message previews | Chat list rows | `ChatPreviewView.kt:41,226-305,308-370` | `previewImage`, `previewVoice`, `previewFile` | Thumbnail, voice duration, file | P, XFTP | CLI sends `/f`, `/_send ... json` voice/image | +| Rows | Group sender prefix | Group rows | `ChatPreviewView.kt:277-283` | `previewGroupSenderPrefix` | A received group message reads "bob: " (`appendSender`, `TextItemView.kt:38-44`); alice's own message has no prefix (`ChatPreviewView.kt:280-283`) | P | – | +| Rows | Report prefix | Group row whose newest main-scope item is a report | `ChatPreviewView.kt:266-275`; `C/views/chat/item/TextItemView.kt:117-118` | `previewReportPrefix` | Report shown with a red italic ": " prefix | P, RESTART, FIXTURE (report item moved to the main scope) | Current cores always put reports in the member-support scope (`src/Simplex/Chat/Library/Internal.hs:1989`), so only an edited item reaches the preview. bob (a member) reports alice's message with a text: `/_report # reason=spam bad` (parser `Library/Commands.hs:6118`; the group id from `/i #team`, "group ID: N", View.hs:1904, the item id from `/_get chat # count=1` read with `nextLine`; the CLI's `/report` always sends empty text, Commands.hs:794-797, and the ": " is added only for a non-empty text, TextItemView.kt:116-118); once alice's row shows the red report flag (`reportsCount > 0`, ChatPreviewView.kt:388), between `restart()` steps `group_scope_tag` and `group_scope_group_member_id` are set to NULL; the preview then reads "Spam: " then "bad" | +| Rows | Unread counter | Badge on the row | `ChatPreviewView.kt:460-501,611` | `unreadCounterIncrementsAndClears`, `unreadCounterGrayWhenMuted` | After bob sends two messages the row badge reads "1" and then "2" (`unreadCountStr`, `ChatPreviewView.kt:611`; absent before the first); opening the chat removes it (items are marked read after 600 ms); in a muted chat the badge still reads "2" and the chat's `chatSettings.enableNtfs` is `MsgFilter.None` and the row shows no mute icon ("Notifications") while the badge is shown (`ChatPreviewView.kt:464,502`); the grey badge colour (`secondary` instead of `primaryVariant`, `:497`) shows in the screenshot. Haskell: `testMarkReadDirect` | P | – | +| Rows | Mentions | Group with a mention of alice | `ChatPreviewView.kt:465-493` | `mentionBadgeSingleUnread`, `mentionIconWithMultipleUnread` | One unread mention shows the round @ badge: icon "Notifications" with no count text (`ChatPreviewView.kt:484-492`); after one more message from bob the row shows the @ icon "Notifications" plus the badge "2" (`:476-483,494-498`); before the mention the row (notifications "All") has neither. Haskell: `testMemberMention` (Groups.hs) | P | – | +| Rows | Status icons | Row trailing icons | `ChatPreviewView.kt:97,384-400,502-527` | `mutedIconWhenNoUnread`, `favoriteStarWhenNoUnread`, `incognitoIconOnRow`, `verifiedIconOnRow`, `timestampShown` | `mutedIconWhenNoUnread`: a muted chat without unread messages shows the mute icon ("Notifications", `ChatPreviewView.kt:502-511`); a message replaces it with the unread badge, and "Mark read" brings it back; `favoriteStarWhenNoUnread`: the same with the star ("Favorite", `:512-521`; not shown when muted); `incognitoIconOnRow`: an incognito contact's row shows the mask (no description, `:557-568`; on group rows only without a report/support/pending flag, `:385-396`); `verifiedIconOnRow`: a verified contact shows the shield before the name (no description, `:97-99,143-145`); `timestampShown`: the row shows `getTimestampText` of the last item (`:417`) | P | – | +| Rows | Connecting text | Pending states | `ChatPreviewView.kt:183-223` | `previewContactShouldAcceptForAddress`, `previewGroupInvitationJoinAs` | `previewContactShouldAcceptForAddress`: alice opens bob's address (no auto-accept), "Open new chat", "Connect", "Send request without message": the row reads "contact should accept…" until bob accepts (shown for a prepared non-bot contact from an address, or a member contact, `ChatPreviewView.kt:194-196`; other pending contacts read "connecting…", `:197-198`); `previewGroupInvitationJoinAs`: an invitation reads "You are invited to group", and one received through an incognito contact reads "Join as " (`:583-588`) | P | "Join as %s" shows only when alice's membership is incognito (`ChatPreviewView.kt:583-587`), so `previewGroupInvitationJoinAs` gets its invitation through an incognito contact | +| Rows | Connecting text | Pending states | `ChatPreviewView.kt:194-198` | `previewConnectingForInvitation` | "connecting…" | P, PEERCTL | The CLI is stopped after creating its 1-time link, so the contact stays "connecting…" after alice taps "Connect" | +| Contact menu | Actions | Right-click a contact row: "Mark read"/"Mark unread", "Favorite"/"Unfavorite", "Mute"/"Unmute", "Add to list", "Clear" | `ChatListNavLinkView.kt:278-301,365-472` | `contactMenuMarkRead`, `contactMenuMarkUnread`, `contactMenuFavoriteUnfavorite`, `contactMenuMuteUnmute`, `contactMenuClearChat`, `contactMenuRightClickOpens` | `contactMenuMarkRead`: "Mark read" (offered while the row has unread messages) clears the unread badge; `contactMenuMarkUnread`: "Mark unread" (offered while the row has no unread messages, `ChatListNavLinkView.kt:39-41,288-292`) sets the chat's unread mark (`markChatUnread`, :677-696; `/_unread chat @ on`), shown as an unread badge with no count (`ChatPreviewView.kt:464,496`); the menu then offers "Mark read" in its place, which clears the mark (`markChatRead`, :643-675); `contactMenuFavoriteUnfavorite`: "Favorite" puts the filled star (content description "Favorite") on the row, shown when the row has no unread badge and is not muted (`ChatPreviewView.kt:512-520`); the menu then offers "Unfavorite", which removes the star; `contactMenuMuteUnmute`: mute shows the mute icon; `contactMenuClearChat`: "Clear chat?" / "Clear" leaves only the chat banner; `contactMenuRightClickOpens`: right-click on the row opens this menu (`D/views/chatlist/ChatListNavLinkView.desktop.kt:40`). CLI messages are local-only (CLI sees nothing). Haskell: `testMuteContact` (1834), `testContactClear` (559), `testAddContact` (`/_unread chat`) | P | Contact mute cycles All↔None (`SimpleXAPI.kt:5437`). "Delete" opens the §15 dialog, tested there | +| 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` | `pendingInvitationRowIconAndName`, `pendingConnectionMenuSetName`, `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); "Set contact name" and a click each open the §10 screen "You invited a contact"; the screen opened from "Set contact name" focuses the name field (`focusAlias = true`, `ChatListNavLinkView.kt:590`), a click opens it unfocused (`ChatView.kt:827`) | — | – | +| 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" / "Accept incognito" / "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`, `contactRequestIncognitoHiddenWithShortLinkData` | Every new request creates a contact (`createRequestEntity` → `createContact'`), shown as a contact row with "Open to accept" (its appearance is `addressCliConnectsAndRequestAppears`, §11). Accept: CLI "alice: contact is connected". Reject: CLI prints nothing, row removed. "Accept incognito" is offered only when the address has no short-link data (`addressShortLinkDataSet()`, `ChatListNavLinkView.kt:537`). Haskell: `testUserContactLink` (1362), `testRejectContactAndDeleteUserContact` (1817), `testConnectIncognitoContactAddress`, `testShortLinkAddressPrepareContact` ("incognito not allowed for address with short link data") | P, S | – | +| Requests | Contact request (contact row) | CLI `/c ` → a contact row with "Open to accept"; row menu "Accept" / "Accept incognito" / "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` | `contactRequestMenuAcceptIncognito` | Accept incognito: CLI sees a random name | P, FIXTURE (address without short-link data) | "Accept incognito" is offered only for an address without short-link data (`addressShortLinkDataSet()`, `ChatListNavLinkView.kt:537`) | +| Requests | Accept bar in the request's chat | Open the "Open to accept" row → chat bar "Reject" / "Accept" | `C/views/chat/ComposeContextContactRequestActionsView.kt:27,82-160`; `ComposeView.kt:1821` | `contactWithRequestOpenToAccept`, `contactWithRequestComposeAccept`, `contactWithRequestComposeReject` | Opening the row shows the chat with the accept bar in place of the message field (`ComposeView.kt:1821-1829`); a request made by CLI `/c

` carries no message (`Connect` takes none, `Library/Commands.hs:6326`; a request message needs `/_connect plan 1 ` → `/_prepare contact 1 ` → `/_connect contact @ text `, H/Profiles.hs:4194-4201). Reject shows "Reject contact request" / "The sender will NOT be notified."; its "Reject" returns to the chat list with the row gone and the CLI prints nothing (`ComposeContextContactRequestActionsView.kt:122-131`; `ChatListNavLinkView.kt:759-771`). Accept: CLI "alice: contact is connected". Haskell: `testShortLinkAddressPrepareContact` (4188) | P | – | +| Requests | Accept bar alert | Open the request's chat for an address without short-link data → "Accept" → "Accept contact request" | `C/views/chat/ComposeContextContactRequestActionsView.kt:91-95,136-165` | `contactWithRequestComposeAcceptAlertIncognito` | "Accept contact request" with "Accept" / "Accept incognito" / "Cancel"; Accept incognito: CLI sees a random name | P, FIXTURE (address without short-link data) | With short-link data (any GUI-created address) "Accept" accepts at once (`ComposeContextContactRequestActionsView.kt:91-95`) | +| Requests | Legacy request row (no contact) | Row "wants to connect to you!"; tap → "Accept connection request?" with "Accept" / "Accept incognito" (not offered while alice's address has short-link data, `ChatModel.kt:276`, ChatListNavLinkView.kt:710) / "Reject"; same in the row menu | `ContactRequestView.kt:22`; `ChatListNavLinkView.kt:125-142,698-773` | `contactRequestLegacyRowWantsToConnect`, `contactRequestTapAlertAccept`, `contactRequestTapAlertReject` | Shown only for a request stored without a contact. Accept: CLI "alice: contact is connected"; Reject removes the row | P, RESTART, FIXTURE (request without contact) | The CLI sends `/c
` live; once alice's chat list shows the CLI's request row ("Open to accept", ChatPreviewView.kt:193; `/c` prints when the request is queued), between `restart()` steps `contact_requests.contact_id` is set to NULL and then the request's contact is deleted (in that order: the column is `REFERENCES contacts ON DELETE CASCADE`, Store/SQLite/Migrations/chat_schema.sql:436, and foreign keys are on, so deleting the contact first would delete the request) (current cores always create the contact, `Store/ContactRequest.hs:195-215`) | +| Requests | Legacy request row (no contact) | Row "wants to connect to you!"; tap → "Accept connection request?" with "Accept" / "Accept incognito" / "Reject"; same in the row menu | `ContactRequestView.kt:22`; `ChatListNavLinkView.kt:125-142,698-773` | `contactRequestTapAlertAcceptIncognito` | "Accept incognito": the CLI is connected with a random name of alice | P, RESTART, FIXTURE (request without contact; address without short-link data) | The CLI sends `/c
` live; once alice's chat list shows the CLI's request row ("Open to accept", ChatPreviewView.kt:193; `/c` prints when the request is queued), between `restart()` steps `contact_requests.contact_id` is set to NULL and then the request's contact is deleted (in that order: the column is `REFERENCES contacts ON DELETE CASCADE`, Store/SQLite/Migrations/chat_schema.sql:436, and foreign keys are on, so deleting the contact first would delete the request) (current cores always create the contact, `Store/ContactRequest.hs:195-215`); the fixture also clears the address's short-link data, since "Accept incognito" is offered only without it (`ChatListNavLinkView.kt:710`) | +| Cards | One-hand toolbar card | "Reachable app toolbars" card: "Bottom bar" / "Top bar", close | `ChatListView.kt:131,1041,1124-1130` | `oneHandToolbarCardSelectBottomBar`, `oneHandToolbarCardDismissShowsAlert`, `oneHandCardAutoHiddenAfterThreeChats` | "Bottom bar" is selected by default (Pref OneHandUI default true, `SimpleXAPI.kt:275`; a selected segment is not clickable, `ChatListView.kt:197`), so the test clicks "Top bar" first: Pref OneHandUI=false, the floating "Start new chat" button appears and the toolbar one is gone; then "Bottom bar": Pref OneHandUI=true, the toolbar button is back and the floating one is gone (`ChatListView.kt:158,167,263,569,585-605`; the list's `reverseLayout` follows the same pref, :994); close shows "Reachable app toolbars" / "You can change it in Appearance settings." and sets `oneHandUICardShown`; the card, shown with 2 chats, is gone once a third chat appears (`:1124-1128`) | — | Close icon has no description. The count is over the filtered chats (`ChatListView.kt:988,1124-1128`), which include the notes folder but not the "Ask SimpleX Team" card, so two added chats reach 3 | +| Cards | Connect banner | Shown when `addressCreationCardShown` is false and there is a non-card, not-deleted direct contact (a contact still waiting to be accepted counts) or any group (`C/views/newchat/OnboardingCards.kt:69-79`); with chats but none of these, the onboarding cards show instead (`OnboardingCards.kt:52-55`): "New 1-time link" / "Paste link", close | `ChatListView.kt:354,1268` | `connectBannerCardOpensNewLink`, `connectBannerCardOpensPasteLink`, `connectBannerCardDismiss` | "New 1-time link" opens "New chat" with the tab "1-time link" selected and, once the link is created, "Share this 1-time invite link" (`NewChatView.kt:131-138,167,500`); "Paste link" opens it with "Connect via link" selected and "Paste the link you received" (`:170,648`; the pager composes only the current page); close sets Pref AddressCreationCardShown=true and the card disappears (`ChatListView.kt:363,1271`) | — | Without `SIMPLEX_ASSETS` it draws a gradient | +| Cards | Crowdfunding banner | "Invest in SimpleX Chat!" / "Crowdfunding on Wefunder" | `C/views/chatlist/GetStakeBanner.kt`; `ChatListView.kt:1087-1097` | `getStakeBannerShownAndDismissed` | Banner shown; the dismiss button appears after the first tap (`GetStakeBanner.kt:75`) and hides it | — | Badge banners are excluded | +| Cards | Crowdfunding banner | "Invest in SimpleX Chat!" / "Crowdfunding on Wefunder" | `C/views/chatlist/GetStakeBanner.kt`; `ChatListView.kt:1087-1097` | `getStakeBannerOpensCrowdfunding` | Tap opens `GetStakeView` (`GetStakeBanner.kt:74-78`); its "Learn more on Wefunder" button is tested in 04 `settingsRoot_crowdfundingOpensGetStake` | — | Badge banners are excluded | +| Center pane | Onboarding cards | Chats exist but no conversations (desktop centre): "Talk to someone", "Let someone connect to you", "Connect via link or QR code", "Create your link", "Invite someone privately", "Create your public address" | `C/App.kt:389-418`; `C/views/newchat/OnboardingCards.kt:52,332` | `centerConnectOnboardingCardsWithNoConversations`, `centerCardsInviteSomeone`, `centerCardsCreatePublicAddress`, `centerCardsConnectViaLink` | `centerConnectOnboardingCardsWithNoConversations`: page "Talk to someone" with the cards "Let someone connect to you" and "Connect via link or QR code" (`OnboardingCards.kt:357-383`); `centerCardsInviteSomeone`: "Let someone connect to you" switches to the page "Create your link", and "Invite someone privately" opens NewChatView (onboarding text "Send the link via any messenger…"); `centerCardsCreatePublicAddress`: "Create your public address" auto-creates the address ("Use this address in your social media profile…"); `centerCardsConnectViaLink`: "Connect via link or QR code" opens NewChatView in onboarding mode with "Paste the link you received" › "Tap to paste link" and no QR scanner on desktop (`OnboardingCards.kt:376-378`; `NewChatView.kt:648-655`) | — | The cards show only while `addressCreationCardShown` is false (`OnboardingCards.kt:52-55`, `App.kt:405`), so its harness pre-set is cleared first | +| 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) + +| 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, 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) + +| 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 | 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 | +| 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 @