ui tests: desktop and Android UI test harness with shared tests

Compose UI tests that drive the real app against local SMP servers and CLI peers:
- harness for desktop (in-process skiko scene, paused clock, parallel forks) and Android (instrumentation on any adb device or Waydroid, CLI peers through a host bridge);
- 112 tests shared by both platforms, 25 desktop-only, 15 Android-only;
- app code: test keys on the elements tests act on, and idle tracking of API calls and events, both inactive unless test hooks are enabled (debug Android builds, desktop tests);
- runners scripts/uitest.sh and scripts/uitest-android.sh, uitest-servers executable (cabal flag uitest);
- plans/ui-tests: the desktop and Android plans, the P1a start page, the Android research log, and the test catalogue with its review notes and scripts.
This commit is contained in:
Narasimha-sc committed 2026-10-14 23:07:29 +00:00
1 parent 000258224c
commit 1b79c35433
183 files changed
+12626 -193

No files matched your search

+6 -2
View File
@@ -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")
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<MainActivity> {
var live = emptyList<MainActivity>()
UiTestApp.onMain {
val monitor = ActivityLifecycleMonitorRegistry.getInstance()
live = Stage.values().filter { it != Stage.DESTROYED }.flatMap { monitor.getActivitiesInStage(it) }.filterIsInstance<MainActivity>()
}
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() }
}
@@ -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) }
}
}
@@ -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<String> =
try {
exchange(cmd)
} catch (e: java.io.IOException) {
runCatching { socket?.close() }
socket = null
throw e
}
private fun exchange(cmd: String): List<String> {
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<String, String> = 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") }
}
@@ -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<String, Map<String, *>> = 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<String>)
else -> error("unsupported preference type ${v.javaClass} for $k")
}
}
}
@@ -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<MainActivity>? = null
private val peers = mutableListOf<CliPeer>()
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<Long>? = 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<String> {
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<String> {
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<String> =
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 <T> 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
@@ -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<File> =
from.listFiles { f -> f.isFile && (f.name.startsWith(chatDatabaseFileName) || f.name.startsWith(agentDatabaseFileName)) }.orEmpty().toList()
}
@@ -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)
+63 -1
View File
@@ -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<Test>("desktopTest") { exclude("chat/simplex/app/ui/**") }
tasks.register<org.jetbrains.kotlin.gradle.targets.jvm.tasks.KotlinJvmTest>("desktopUiTest") {
targetName = "desktop"
onlyIf { System.getenv("SIMPLEX_UITEST_RUNNER") == "1" }
val dt = tasks.named<Test>("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<Test>("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))
}
}
@@ -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<out String>?,
@@ -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
}
@@ -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<Boolean>,
nextChatSelected: State<Boolean>,
uiKey: UiKey?,
) {
var modifier = Modifier.fillMaxWidth()
var modifier = Modifier.fillMaxWidth().ui(uiKey)
if (!disabled) modifier = modifier
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
@@ -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)
@@ -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<OnboardingStage>, 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)
}
@@ -839,7 +839,7 @@ object ChatModel {
private val chatsToPop = mutableMapOf<ChatId, Instant>()
init {
withLongRunningApi {
withLongRunningApi(idle = false) {
subject
.throttleLatest(2000)
.collect {
@@ -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
@@ -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)
}
}
@@ -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<Long, Item>()
private val abandoned = ConcurrentHashMap.newKeySet<Long>()
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 <T> track(noinline what: (() -> String)?, block: () -> T): T {
val id = if (what == null) 0 else start(what)
try {
return block()
} finally {
finish(id)
}
}
inline fun <T> trackEvent(noinline what: () -> String, block: () -> T): T =
try {
track(what, block)
} finally {
if (uiTestHooksEnabled) eventProcessed()
}
fun eventProcessed() {
lastEventAt = System.currentTimeMillis()
}
fun inFlight(): List<Item> = items.values.filter { it.id !in abandoned }.sortedBy { it.id }
fun abandon(stuck: List<Item>) {
stuck.forEach { abandoned.add(it.id) }
}
}
@@ -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<Int>) {
}
}
},
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<String>, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) {
fun ProfileNameField(name: MutableState<String>, 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<String>, 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<String>, 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)
}
}
@@ -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,
)
}
@@ -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,
@@ -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
@@ -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() } }
)
}
@@ -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<Set<Long>?>
onTitleClick = null,
onTop = onTop,
onSearchValueChanged = {},
buttons = if (selectAll != null) { { SelectAllButton(selectAll) } } else {{}}
buttons = if (selectAll != null) { { SelectAllButton(selectAll) } } else {{}},
uiKey = Ui.Chat.Selection
)
}
}
@@ -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) {
@@ -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) {
@@ -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))
@@ -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()) {
@@ -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
)
}
@@ -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<Boolean>,
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<Long>, 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) }
}
}
}
@@ -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<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
uiKey = Ui.ChatList.Row(chat.id, chat.remoteHostId),
)
}
is ChatInfo.Group -> {
@@ -100,6 +103,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
uiKey = Ui.ChatList.Row(chat.id, chat.remoteHostId),
)
}
is ChatInfo.Local -> {
@@ -120,6 +124,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
uiKey = Ui.ChatList.Row(chat.id, chat.remoteHostId),
)
}
is ChatInfo.ContactRequest ->
@@ -132,13 +137,14 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
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<Boolean>) {
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<Boolean>) {
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<Boolean>, 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<Boolean>) {
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<Boolean>) {
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<Boolean>) {
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<Boolean>, onSuccess: ((chat: Chat) -> Unit)? = null) {
fun ContactRequestMenuItems(rhId: Long?, contactRequestId: Long, chatModel: ChatModel, showMenu: MutableState<Boolean>, 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<Boolean>,
nextChatSelected: State<Boolean>,
uiKey: UiKey? = null,
)
@Preview/*(
@@ -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<Boolean>, 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<AnimatedViewState>
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<AnimatedViewState>
onClick = {
showNewChatSheet(oneHandUI)
},
Modifier.ui(Ui.ChatList.NewChat),
) {
Box(
contentAlignment = Alignment.Center,
@@ -622,7 +628,7 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
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<AnimatedViewState>
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<TextFieldVa
val view = LocalMultiplatformView()
Row(
Modifier
.ui(Ui.ChatList.ConnectByName)
.fillMaxWidth()
.clickable {
hideKeyboard(view)
@@ -1243,7 +1252,7 @@ private fun NoChatsView(searchText: MutableState<TextFieldValue>) {
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<TextFieldValue>) {
Box {
Row(
rowSizeModifier
.ui(Ui.ChatList.UserTag(tag.chatTagId))
.clip(shape = CircleShape)
.combinedClickable(
onClick = {
@@ -1388,7 +1398,7 @@ private fun TagsView(searchText: MutableState<TextFieldValue>) {
}
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<TextFieldValue>) {
} 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<TextFieldValue>) {
}
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<TextFieldValue>) {
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)
@@ -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)
}
}
}
@@ -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))
@@ -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<Bo
deleteTagDialog(rhId, tag, saving)
showMenu.value = false
},
color = Color.Red
color = Color.Red,
uiKey = Ui.ChatList.UserTagMenu(tag.chatTagId, "delete")
)
}
@@ -332,7 +336,8 @@ private fun EditTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState<Bool
)
}
},
color = MenuTextColor
color = MenuTextColor,
uiKey = Ui.ChatList.UserTagMenu(tag.chatTagId, "edit")
)
}
@@ -370,6 +375,7 @@ fun TagListNameTextField(name: MutableState<String>, showError: State<Boolean>)
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<Bool
SectionItemView({
AlertManager.shared.hideAlert()
deleteTag(rhId, tag, saving)
}) {
}, uiKey = Ui.Alert.Confirm(Ui.AlertSource.Shared)) {
Text(
generalGetString(MR.strings.confirm_verb),
Modifier.fillMaxWidth(),
@@ -26,6 +26,8 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.Ui
import chat.simplex.common.ui.UiKey
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
@@ -209,7 +211,7 @@ fun UserPicker(
)
}
} else if (currentUser != null) {
SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped, uiKey = Ui.UserPicker.User(currentUser.userId)) {
ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped, badge = currentUser.profile.localBadge)
}
}
@@ -230,7 +232,8 @@ fun UserPicker(
if (ModalManager.start.isLastModal(modal)) close()
}
}
}, disabled = stopped
}, disabled = stopped,
uiKey = Ui.UserPicker.Address
)
UserPickerOptionRow(
painterResource(MR.images.ic_toggle_on),
@@ -315,7 +318,8 @@ fun UserPicker(
})
}
},
disabled = stopped
disabled = stopped,
uiKey = Ui.UserPicker.Profiles
)
}
}
@@ -402,7 +406,8 @@ private fun GlobalSettingsSection(
SettingsView(chatModel, setPerformLA, close)
}
},
padding = if (appPlatform.isDesktop) PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING + 2.dp) else PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF)
padding = if (appPlatform.isDesktop) PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING + 2.dp) else PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF),
uiKey = Ui.UserPicker.Settings,
) {
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_settings), text, tint = MaterialTheme.colors.secondary)
@@ -498,8 +503,8 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning
}
@Composable
fun UserPickerOptionRow(icon: Painter, text: String, click: (() -> 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)
@@ -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()
@@ -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) {
@@ -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 <T> ExposedDropDownSetting(
enabled: State<Boolean> = 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 <T> 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 <T> 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 <T> ExposedDropDownSettingRow(
enabled: State<Boolean> = 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)
}
}
@@ -16,7 +16,7 @@ class ProcessedErrors <T: AgentErrorType>(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)
@@ -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(
@@ -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,
@@ -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()
@@ -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(),
@@ -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<Triple<Painter, String, () -> Unit>>) {
fun DeletedChatsItem(actionButtons: List<NewChatAction>) {
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)
@@ -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<String>, 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)
}) {
@@ -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<Set<Lo
fontWeight = FontWeight.Medium,
color = MaterialTheme.colors.primary,
modifier = Modifier
.ui(Ui.Onboarding.ConditionsLink)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
@@ -197,7 +201,8 @@ fun ModalData.ChooseServerOperators(
Column(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingInformationButton(
stringResource(MR.strings.how_it_helps_privacy),
onClick = { ModalManager.fullscreen.showModal { ChooseServerOperatorsInfoView() } }
onClick = { ModalManager.fullscreen.showModal { ChooseServerOperatorsInfoView() } },
uiKey = Ui.Onboarding.HowItHelps
)
}
@@ -240,6 +245,7 @@ private fun OperatorCheckView(serverOperator: ServerOperator, selectedOperatorId
selectedOperatorIds.value += serverOperator.operatorId
}
},
modifier = Modifier.ui(Ui.Onboarding.Operator(serverOperator.operatorId)),
border = BorderStroke(1.dp, color = if (checked) MaterialTheme.colors.primary else MaterialTheme.colors.secondary.copy(alpha = 0.5f)),
shape = RoundedCornerShape(18.dp)
) {
@@ -280,7 +286,7 @@ private fun CircleCheckbox(checked: Boolean) {
@Composable
private fun SetOperatorsButton(enabled: Boolean, close: () -> 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<Set<Long>>
) {
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,
@@ -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
@@ -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() }
) {
@@ -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)
) {
@@ -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<List<ServerOperator>>, selectedOperatorIds: State<Set<Long>>, onClick: () -> Unit) {
Box(
modifier = Modifier
.ui(Ui.Onboarding.SetupRouters)
.clip(CircleShape)
.clickable { onClick() }
) {
@@ -206,6 +209,7 @@ private fun ConfigureNotificationsButton(notificationMode: State<NotificationsMo
}
Box(
modifier = Modifier
.ui(Ui.Onboarding.SetupNotifications)
.clip(CircleShape)
.clickable { onClick() }
) {
@@ -29,6 +29,8 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.platform.*
import chat.simplex.common.ui.Ui
import chat.simplex.common.ui.ui
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
@@ -158,7 +160,7 @@ fun DeviceNameField(
DefaultConfigurableTextField(
state = state,
placeholder = generalGetString(MR.strings.enter_this_device_name),
modifier = Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING),
modifier = Modifier.ui(Ui.Remote.DeviceName).fillMaxWidth().padding(start = DEFAULT_PADDING),
isValid = { true },
)
KeyChangeEffect(state.value) {
@@ -207,7 +209,7 @@ private fun ConnectMobileViewLayout(
if (remember { controller.appPrefs.developerTools.state }.value) {
val clipboard = LocalClipboardManager.current
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
SectionItemView({ clipboard.shareText(invitation) }) {
SectionItemView({ clipboard.shareText(invitation) }, uiKey = Ui.Remote.ShareLink) {
Text(generalGetString(MR.strings.share_link), color = MaterialTheme.colors.primary)
}
}
@@ -31,6 +31,8 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.Ui
import chat.simplex.common.ui.ui
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
@@ -231,6 +233,7 @@ object AppearanceScope {
if (appPlatform.isAndroid) {
Slider(
localFontScale.value,
modifier = Modifier.ui(Ui.Appearance.FontScale),
valueRange = 0.75f..1.25f,
steps = 11,
onValueChange = {
@@ -248,6 +251,7 @@ object AppearanceScope {
} else {
Slider(
localFontScale.value,
modifier = Modifier.ui(Ui.Appearance.FontScale),
valueRange = 0.7f..1.5f,
steps = 9,
onValueChange = {
@@ -1157,6 +1161,7 @@ object AppearanceScope {
state,
icon = null,
enabled = remember { mutableStateOf(true) },
uiKey = Ui.Appearance.ColorMode,
onSelected = onSelected
)
}
@@ -18,6 +18,8 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.UiKey
import chat.simplex.common.ui.ui
import chat.simplex.res.*
@Composable
@@ -85,9 +87,11 @@ private fun LockscreenOpts(lockscreenOpts: State<CallOnLockScreen>, enabled: Sta
fun SharedPreferenceToggle(
preference: SharedPreference<Boolean>,
enabled: Boolean = true,
uiKey: UiKey? = null,
onChange: ((Boolean) -> Unit)? = null,
) {
DefaultSwitch(
modifier = Modifier.ui(uiKey),
enabled = enabled,
checked = remember { preference.state }.value,
onCheckedChange = {
@@ -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<Boolean>,
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)
}
}
@@ -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,
)
}
@@ -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),
@@ -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)
@@ -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
@@ -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()
@@ -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<Boolean>,
nextChatSelected: State<Boolean>,
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 }
@@ -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),
@@ -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
) {
@@ -168,7 +168,7 @@ fun setupUpdateChecker() = withLongRunningApi {
}
private fun createUpdateJob() {
updateCheckerJob = withLongRunningApi {
updateCheckerJob = withLongRunningApi(idle = false) {
delay(24 * 60 * 60 * 1000)
checkForUpdate()
createUpdateJob()
@@ -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<OnboardingStage>, 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)
@@ -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 = {
@@ -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))
}
}
}
@@ -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"
}
}
@@ -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
}
}
@@ -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<String> = 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
}
}
@@ -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<File> =
dir.listFiles { f -> f.isFile && (f.name.startsWith(chatDatabaseFileName) || f.name.startsWith(agentDatabaseFileName)) }.orEmpty().toList()
}
@@ -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())
}
}
@@ -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")
}
}
@@ -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<Long>()
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}")
}
}
@@ -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"))
}
}
@@ -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<File>())) {
@Suppress("UNCHECKED_CAST")
val d = (onResult.get(ds) as MutableState<CompletableDeferred<Any?>?>).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)
}
@@ -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<String>()
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()
}
}
@@ -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<String>()
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()
}
}
@@ -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<String, CliPeer>()
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<String>) {
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<String> {
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]}")
}
}
}
@@ -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")
}
@@ -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<CliPeer>()
@Synchronized fun add(p: CliPeer) { running += p }
@Synchronized fun stopAll() { running.forEach { runCatching { it.stop() } }; running.clear() }
}
@@ -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<String> {
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<String> {
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<Long>? = 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<String> =
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")
@@ -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<Pair<NotificationAction, () -> Unit>>) {}
override fun androidCreateNtfChannelsMaybeShowAlert() {}
override fun cancelCallNotification() {}
override fun cancelAllNotifications() {}
override fun showMessage(title: String, text: String) {}
}
}
@@ -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()
}
}
@@ -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))
}
}
}
@@ -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"
}
}
@@ -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 }
}
}
}
@@ -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 } }
}
}
@@ -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" }
}
}
@@ -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 }
}
}
@@ -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 🙂"
}
}
@@ -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)")
}
}
@@ -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")
}
}
@@ -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 } }
}
}
@@ -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 ")
@@ -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<String> = s.textsOf(chip(name))
fun activeTagId(): Long? = (chatModel.activeChatTagFilter.value as? ActiveFilter.UserTag)?.tag?.chatTagId
fun tagsOf(chat: String): List<Long> = 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" }
}
}
@@ -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
}
@@ -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]
}
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more