mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 10:47:32 +00:00
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:
1 parent
000258224c
commit
1b79c35433
183 files changed
+12626
-193
No files matched your search
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
-22
@@ -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)
|
||||
}
|
||||
}
|
||||
+50
@@ -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() }
|
||||
}
|
||||
+197
@@ -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) }
|
||||
}
|
||||
}
|
||||
+54
@@ -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") }
|
||||
}
|
||||
+47
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+354
@@ -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
|
||||
+153
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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>?,
|
||||
|
||||
+8
@@ -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
|
||||
}
|
||||
+4
-1
@@ -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 })
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -839,7 +839,7 @@ object ChatModel {
|
||||
private val chatsToPop = mutableMapOf<ChatId, Instant>()
|
||||
|
||||
init {
|
||||
withLongRunningApi {
|
||||
withLongRunningApi(idle = false) {
|
||||
subject
|
||||
.throttleLatest(2000)
|
||||
.collect {
|
||||
|
||||
+5
-4
@@ -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) }
|
||||
}
|
||||
}
|
||||
+13
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+2
@@ -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() } }
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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))
|
||||
|
||||
+3
-1
@@ -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()) {
|
||||
|
||||
+4
-1
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+35
-27
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-9
@@ -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/*(
|
||||
|
||||
+29
-15
@@ -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)
|
||||
|
||||
+2
-1
@@ -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
-4
@@ -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))
|
||||
|
||||
+10
-4
@@ -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(),
|
||||
|
||||
+11
-6
@@ -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)
|
||||
|
||||
+12
-7
@@ -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()
|
||||
|
||||
+5
-2
@@ -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) {
|
||||
|
||||
+8
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+8
-1
@@ -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(
|
||||
|
||||
+6
-2
@@ -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,
|
||||
|
||||
+12
-4
@@ -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()
|
||||
|
||||
+3
-1
@@ -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(),
|
||||
|
||||
+17
-22
@@ -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)
|
||||
|
||||
+9
-2
@@ -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)
|
||||
}) {
|
||||
|
||||
+9
-3
@@ -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,
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
+9
-4
@@ -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() }
|
||||
) {
|
||||
|
||||
+4
@@ -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)
|
||||
) {
|
||||
|
||||
+6
-2
@@ -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() }
|
||||
) {
|
||||
|
||||
+4
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+4
@@ -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 = {
|
||||
|
||||
+9
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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),
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+3
@@ -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()
|
||||
|
||||
+4
-1
@@ -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 }
|
||||
|
||||
+3
@@ -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),
|
||||
|
||||
+4
@@ -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
|
||||
) {
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ fun setupUpdateChecker() = withLongRunningApi {
|
||||
}
|
||||
|
||||
private fun createUpdateJob() {
|
||||
updateCheckerJob = withLongRunningApi {
|
||||
updateCheckerJob = withLongRunningApi(idle = false) {
|
||||
delay(24 * 60 * 60 * 1000)
|
||||
checkForUpdate()
|
||||
createUpdateJob()
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
+3
@@ -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 = {
|
||||
|
||||
+46
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+302
@@ -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"
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+133
@@ -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
|
||||
}
|
||||
}
|
||||
+43
@@ -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()
|
||||
}
|
||||
+14
@@ -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())
|
||||
}
|
||||
}
|
||||
+17
@@ -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")
|
||||
}
|
||||
}
|
||||
+28
@@ -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}")
|
||||
}
|
||||
}
|
||||
+34
@@ -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)
|
||||
}
|
||||
|
||||
+27
@@ -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()
|
||||
}
|
||||
}
|
||||
+59
@@ -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()
|
||||
}
|
||||
}
|
||||
+61
@@ -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() }
|
||||
}
|
||||
+358
@@ -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")
|
||||
+136
@@ -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) {}
|
||||
}
|
||||
}
|
||||
+103
@@ -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()
|
||||
}
|
||||
}
|
||||
+25
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
}
|
||||
+20
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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 } }
|
||||
}
|
||||
}
|
||||
+75
@@ -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" }
|
||||
}
|
||||
}
|
||||
+15
@@ -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 }
|
||||
}
|
||||
}
|
||||
+14
@@ -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 🙂"
|
||||
}
|
||||
}
|
||||
+45
@@ -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)")
|
||||
}
|
||||
}
|
||||
+16
@@ -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")
|
||||
}
|
||||
}
|
||||
+28
@@ -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 } }
|
||||
}
|
||||
}
|
||||
+104
@@ -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 ")
|
||||
|
||||
+84
@@ -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" }
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
}
|
||||
|
||||
+25
@@ -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
Reference in new issue
Block a user