mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-12 15:58:03 +00:00
ui tests: about 300 more tests across all areas, server control, relays, remote desktop, CI workflow
- Tests for connection plans, prepared chats, edit/delete/selection, forwarding, drafts, group preferences and admission, group lifecycle, compose, live messages, item info, reports and moderation, privacy and SimpleX Lock, chat data and database, address and contact preferences, profiles, contact info, unread and search, files and reactions, group info and events, chat shell and statuses, joining, support and business chats, settings and appearance, network and servers, channels, remote desktop, Android intents and notifications. - Harness: server stop/start, smp2/xftp2, SOCKS stub, CLI chat relays, CLI remote host/controller, chat scroll and drag helpers, file chooser answers, clipboard in popups. - ui-tests workflow (manual dispatch only) running both suites on GitHub runners. - App findings recorded in plan section 15.5 (items 7-23), each covered by a test marked @KnownAppIssue.
This commit is contained in:
1 parent
8927c18925
commit
69b3cc4f0e
256 files changed
+21715
-1093
No files matched your search
+125
@@ -0,0 +1,125 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.ComponentName
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.PowerManager
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.espresso.UiController
|
||||
import androidx.test.espresso.ViewAction
|
||||
import androidx.test.espresso.ViewInteraction
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
|
||||
import androidx.test.runner.lifecycle.Stage
|
||||
import androidx.work.WorkInfo
|
||||
import chat.simplex.app.SimplexService
|
||||
import chat.simplex.app.ui.harness.UiScope
|
||||
import chat.simplex.app.ui.harness.UiTestApp
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.platform.getWorkManagerInstance
|
||||
import chat.simplex.common.ui.Ui
|
||||
import chat.simplex.common.ui.UiKey
|
||||
import chat.simplex.common.views.usersettings.changeNotificationsMode
|
||||
import org.hamcrest.CoreMatchers
|
||||
import org.hamcrest.Matcher
|
||||
|
||||
internal fun shell(cmd: String): String =
|
||||
ParcelFileDescriptor.AutoCloseInputStream(InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(cmd)).bufferedReader().use { it.readText() }
|
||||
|
||||
internal fun serviceNotification(): Notification? =
|
||||
androidAppContext.getSystemService(NotificationManager::class.java).activeNotifications.firstOrNull { it.id == SimplexService.SIMPLEX_SERVICE_ID }?.notification
|
||||
|
||||
internal fun receiverState(receiver: Class<*>): Int =
|
||||
androidAppContext.packageManager.getComponentEnabledSetting(ComponentName(androidAppContext.packageName, receiver.name))
|
||||
|
||||
internal fun uniqueWorkActive(name: String): Boolean =
|
||||
androidAppContext.getWorkManagerInstance().getWorkInfosForUniqueWork(name).get().any { !it.state.isFinished }
|
||||
|
||||
internal fun workStates(name: String): List<WorkInfo.State> =
|
||||
androidAppContext.getWorkManagerInstance().getWorkInfosForUniqueWork(name).get().map { it.state }
|
||||
|
||||
internal fun UiScope.withBackgroundServiceAllowed(body: () -> Unit) {
|
||||
val pkg = androidAppContext.packageName
|
||||
val allowListed = androidAppContext.getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(pkg)
|
||||
if (!allowListed) shell("dumpsys deviceidle whitelist +$pkg")
|
||||
val result = runCatching {
|
||||
poll(what = "the app allowed to run in background (battery allow-list, no background restriction)") { SimplexService.isBackgroundAllowed() }
|
||||
body()
|
||||
}
|
||||
val stopped = runCatching {
|
||||
UiTestApp.onMain { changeNotificationsMode(NotificationsMode.OFF, chatModel) }
|
||||
poll(what = "the SimpleX Chat service to stop and its notification to go") { !SimplexService.isServiceStarted && !SimplexService.isServiceStarting && serviceNotification() == null }
|
||||
}
|
||||
if (!allowListed) shell("dumpsys deviceidle whitelist -$pkg")
|
||||
result.getOrThrow()
|
||||
stopped.getOrThrow()
|
||||
}
|
||||
|
||||
internal class WindowFrame(val height: Int, val keyboardTop: Int)
|
||||
|
||||
internal fun windowFrame(): WindowFrame {
|
||||
var frame: WindowFrame? = null
|
||||
UiTestApp.onMain {
|
||||
val decor = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).firstOrNull()?.window?.decorView
|
||||
val insets = decor?.let { ViewCompat.getRootWindowInsets(it) }
|
||||
if (decor != null && insets != null) frame = WindowFrame(decor.height, decor.height - insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
|
||||
}
|
||||
return frame ?: error("no resumed activity window to measure")
|
||||
}
|
||||
|
||||
internal fun UiScope.settledKeyboardTop(): Int {
|
||||
var last = -1
|
||||
poll(what = "the soft keyboard to finish opening") {
|
||||
val f = windowFrame()
|
||||
(keyboardShown() && f.keyboardTop < f.height && f.keyboardTop == last).also { last = f.keyboardTop }
|
||||
}
|
||||
settle()
|
||||
return windowFrame().keyboardTop
|
||||
}
|
||||
|
||||
internal fun UiScope.boundsInWindow(key: UiKey): Rect {
|
||||
waitFor(key)
|
||||
return rule.onNode(matcher(key)).fetchSemanticsNode().boundsInWindow
|
||||
}
|
||||
|
||||
internal fun UiScope.toggleState(key: UiKey): ToggleableState? {
|
||||
waitFor(key)
|
||||
return rule.onNode(matcher(key)).fetchSemanticsNode().config.getOrElseNullable(SemanticsProperties.ToggleableState) { null }
|
||||
}
|
||||
|
||||
internal fun messageEditText(): ViewInteraction =
|
||||
Espresso.onView(CoreMatchers.allOf(ViewMatchers.isAssignableFrom(EditText::class.java), ViewMatchers.withTagValue(CoreMatchers.equalTo<Any>(Ui.Chat.Message.tag))))
|
||||
|
||||
internal fun messageLineCount(): Int {
|
||||
var lines = -1
|
||||
messageEditText().check { v, e -> lines = (v as? EditText)?.lineCount ?: throw (e ?: AssertionError("the message field is not an EditText")) }
|
||||
return lines
|
||||
}
|
||||
|
||||
internal fun pressImeEnter() {
|
||||
messageEditText().perform(ImeEnterKey())
|
||||
}
|
||||
|
||||
private class ImeEnterKey : ViewAction {
|
||||
override fun getConstraints(): Matcher<View> = ViewMatchers.isAssignableFrom(EditText::class.java)
|
||||
override fun getDescription() = "send the Enter key through the input connection, as the soft keyboard does"
|
||||
override fun perform(uiController: UiController, view: View) {
|
||||
val ic = (view as EditText).onCreateInputConnection(EditorInfo()) ?: error("the message field has no input connection")
|
||||
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
|
||||
ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER))
|
||||
uiController.loopMainThreadUntilIdle()
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import chat.simplex.app.MainActivity
|
||||
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
|
||||
import chat.simplex.app.ui.harness.CliPeer
|
||||
import chat.simplex.app.ui.harness.UiScope
|
||||
import chat.simplex.app.ui.harness.UiTest
|
||||
import chat.simplex.app.ui.harness.createOneTimeLink
|
||||
import chat.simplex.app.ui.harness.planConfirm
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.Ui
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AppLinksAndroidTest : UiTest() {
|
||||
@Test fun appLink_oneTimeLink_opensPlan() = uiTest {
|
||||
val alice = app("alice"); val bob = cli("bob")
|
||||
val link = bobsOneTimeLink(bob)
|
||||
alice.chatList.row(NOTES)
|
||||
check(chatModel.chats.value.none { it.chatInfo.chatViewName == "bob" } && !exists(planConfirm)) { "bob's chat or a plan alert exists before the link is opened" }
|
||||
deliverToApp(viewIntent(link))
|
||||
awaitPlanForBob()
|
||||
click(planConfirm)
|
||||
poll(what = "bob's prepared chat open with Connect") { preparedBobOpen() && exists(Ui.Chat.Connect) }
|
||||
click(Ui.Chat.Connect)
|
||||
assertEquals("alice: contact is connected", bob.nextLine(CONNECT_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
@Test fun appLink_noData_showsInvalidLinkAlert() = uiTest {
|
||||
app("alice").chatList.row(NOTES)
|
||||
check(!AlertManager.shared.hasAlertsShown() && !seesText(INVALID_LINK)) { "an alert is shown before the link is opened" }
|
||||
deliverToApp(viewIntent(null))
|
||||
poll(what = "the `$INVALID_LINK` alert") { seesText(INVALID_LINK) && seesText("Please check that SimpleX link is correct.") }
|
||||
check(!exists(planConfirm) && !AlertManager.privacySensitive.hasAlertsShown()) { "a link without data opened a connection plan" }
|
||||
click(Ui.Alert.Confirm(Ui.AlertSource.Shared))
|
||||
poll(what = "the alert closed over the chat list") { !AlertManager.shared.hasAlertsShown() && exists(Ui.ChatList.Search) }
|
||||
}
|
||||
|
||||
@Test fun appLink_whileChatStopped_openedAfterStart() = uiTest {
|
||||
val alice = app("alice"); val bob = cli("bob")
|
||||
val link = bobsOneTimeLink(bob)
|
||||
stopChatFromChatData(alice)
|
||||
deliverToApp(viewIntent(link))
|
||||
poll(what = "the link queued by the app") { chatModel.appOpenUrl.value?.second == link }
|
||||
settle()
|
||||
check(chatModel.appOpenUrl.value?.second == link && !exists(planConfirm) && !AlertManager.privacySensitive.hasAlertsShown()) {
|
||||
"the link was handled while the chat was stopped: queued ${chatModel.appOpenUrl.value}, texts ${texts().take(20)}"
|
||||
}
|
||||
click(Ui.Database.RunChat)
|
||||
awaitPlanForBob()
|
||||
check(chatModel.chatRunning.value == true && chatModel.appOpenUrl.value == null) { "the plan opened with the chat running ${chatModel.chatRunning.value}, queued ${chatModel.appOpenUrl.value}" }
|
||||
click(planConfirm)
|
||||
poll(what = "bob's prepared chat open in place of Chat data") { preparedBobOpen() && !exists(Ui.Database.RunChat) }
|
||||
}
|
||||
|
||||
@Test fun appLink_intentFilter_matchesOnlyConnectionPaths() = uiTest {
|
||||
val notOpening = OPENS.filterNot(::opensMainActivity)
|
||||
val wronglyOpening = IGNORED.filter(::opensMainActivity)
|
||||
check(notOpening.isEmpty() && wronglyOpening.isEmpty()) {
|
||||
"links that must open the app but do not: $notOpening; links that open the app but must not: $wronglyOpening"
|
||||
}
|
||||
}
|
||||
|
||||
private fun bobsOneTimeLink(bob: CliPeer): String =
|
||||
bob.createOneTimeLink().also { bob.eventually("and ask them to connect: /c <invitation_link_above>") }
|
||||
|
||||
private fun UiScope.awaitPlanForBob() =
|
||||
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the plan alert for bob with `Open new chat`") { exists(planConfirm) && "Open new chat" in textsOf(planConfirm) && seesText("bob") }
|
||||
|
||||
private fun preparedBobOpen(): Boolean =
|
||||
chatModel.chats.value.firstOrNull { it.chatInfo.chatViewName == "bob" && it.chatInfo.nextConnectPrepared }?.id?.let { it == chatModel.chatId.value } == true
|
||||
|
||||
private fun opensMainActivity(link: String): Boolean =
|
||||
androidAppContext.packageManager.queryIntentActivities(Intent(Intent.ACTION_VIEW, Uri.parse(link)).setPackage(androidAppContext.packageName), 0)
|
||||
.any { it.activityInfo.name == MainActivity::class.java.name }
|
||||
|
||||
private companion object {
|
||||
const val NOTES = "Private notes"
|
||||
const val INVALID_LINK = "Invalid link"
|
||||
val OPENS = listOf(
|
||||
"simplex:/contact#/?v=2-7&smp=x",
|
||||
"simplexchat:/open",
|
||||
"https://simplex.chat/contact#/?v=2-7&smp=x",
|
||||
"https://simplex.chat/invitation#/?v=2-7&smp=x",
|
||||
"https://simplex.chat/a#key",
|
||||
"https://simplex.chat/c#key",
|
||||
"https://simplex.chat/g#key",
|
||||
"https://simplex.chat/i#key",
|
||||
"https://simplex.chat/contact/#/?v=2-7&smp=x",
|
||||
"https://simplex.chat/i/#key",
|
||||
"https://smp4.simplex.im/a#key",
|
||||
"https://smp19.simplex.im/i#key",
|
||||
"https://smp1.simplexonflux.com/c#key",
|
||||
"https://smp6.simplexonflux.com/g#key",
|
||||
)
|
||||
val IGNORED = listOf(
|
||||
"https://simplex.chat/",
|
||||
"https://simplex.chat/crowdfunding/",
|
||||
"https://simplex.chat/blog/",
|
||||
"https://simplex.chat/contacts",
|
||||
"https://simplex.chat/about",
|
||||
"https://localhost/a#key",
|
||||
"https://example.com/i#key",
|
||||
)
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import chat.simplex.app.ui.harness.UiScope
|
||||
import chat.simplex.app.ui.harness.UiTest
|
||||
import chat.simplex.app.ui.harness.backToChatList
|
||||
import chat.simplex.common.ui.Ui
|
||||
import chat.simplex.common.ui.UiKey
|
||||
import chat.simplex.common.views.helpers.ModalManager
|
||||
import org.junit.Test
|
||||
|
||||
class AppearanceAndroidTest : UiTest() {
|
||||
@Test fun appearanceAndroid_interfaceSectionToolbars() = uiTest {
|
||||
val alice = app("alice")
|
||||
waitFor(Ui.ChatList.Search)
|
||||
check(storedSetting(ONE_HAND) == null && storedSetting(CHAT_BOTTOM) == null) { "the toolbar prefs are not at their defaults: ${storedSetting(ONE_HAND)}, ${storedSetting(CHAT_BOTTOM)}" }
|
||||
poll(what = "the chat list search field and toolbar at the bottom of the screen") { lowerHalf(Ui.ChatList.Search) == true && lowerHalf(Ui.ChatList.Bar) == true }
|
||||
alice.chatList.open(NOTES)
|
||||
poll(what = "the notes toolbar at the bottom of the screen") { lowerHalf(CHAT_TITLE) == true }
|
||||
backToChatList()
|
||||
|
||||
alice.appearance()
|
||||
check(toggleState(Ui.Appearance.OneHandUI) == ToggleableState.On && toggleState(Ui.Appearance.ChatBottomBar) == ToggleableState.On) {
|
||||
"Reachable app toolbars ${toggleState(Ui.Appearance.OneHandUI)}, Reachable chat toolbar ${toggleState(Ui.Appearance.ChatBottomBar)}"
|
||||
}
|
||||
click(Ui.Appearance.ChatBottomBar)
|
||||
poll(what = "Reachable chat toolbar off") { storedSetting(CHAT_BOTTOM) == "false" && toggleState(Ui.Appearance.ChatBottomBar) == ToggleableState.Off }
|
||||
click(Ui.Appearance.OneHandUI)
|
||||
poll(what = "Reachable app toolbars off, hiding Reachable chat toolbar") {
|
||||
storedSetting(ONE_HAND) == "false" && toggleState(Ui.Appearance.OneHandUI) == ToggleableState.Off && !exists(Ui.Appearance.ChatBottomBar)
|
||||
}
|
||||
closeAppearance()
|
||||
poll(what = "the chat list search field and toolbar at the top of the screen") { lowerHalf(Ui.ChatList.Search) == false && lowerHalf(Ui.ChatList.Bar) == false }
|
||||
|
||||
alice.appearance()
|
||||
click(Ui.Appearance.OneHandUI)
|
||||
poll(what = "Reachable app toolbars on, showing Reachable chat toolbar turned on again") {
|
||||
storedSetting(ONE_HAND) == "true" && exists(Ui.Appearance.ChatBottomBar) && toggleState(Ui.Appearance.ChatBottomBar) == ToggleableState.On && storedSetting(CHAT_BOTTOM) == "true"
|
||||
}
|
||||
}
|
||||
|
||||
private fun UiScope.lowerHalf(key: UiKey): Boolean? =
|
||||
rule.onAllNodes(matcher(key)).fetchSemanticsNodes().firstOrNull()?.boundsInWindow?.let { it.top >= windowFrame().height / 2f }
|
||||
|
||||
private fun UiScope.closeAppearance() {
|
||||
escape()
|
||||
poll(what = "Your settings again") { exists(Ui.Settings.Appearance) && !exists(Ui.Appearance.OneHandUI) }
|
||||
escape()
|
||||
poll(what = "the chat list with nothing open") { !ModalManager.start.hasModalsOpen() && !exists(Ui.Settings.Appearance) && !exists(Ui.UserPicker.Sheet) && exists(Ui.ChatList.Search) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ONE_HAND = "OneHandUI"
|
||||
const val CHAT_BOTTOM = "ChatBottomBar"
|
||||
const val NOTES = "Private notes"
|
||||
val CHAT_TITLE = Ui.Chat.Bar.part("title")
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
|
||||
import chat.simplex.app.ui.harness.UiTest
|
||||
import chat.simplex.app.ui.harness.connect
|
||||
import chat.simplex.app.ui.harness.described
|
||||
import chat.simplex.common.ui.Ui
|
||||
import org.junit.Test
|
||||
|
||||
class ComposeKeyboardAndroidTest : UiTest() {
|
||||
@Test fun compose_enterKeyInsertsNewline_sendButtonSends() = uiTest {
|
||||
val alice = app("alice"); val bob = cli("bob")
|
||||
connect(bob)
|
||||
val chat = alice.chatList.open("bob")
|
||||
typeInto(Ui.Chat.Message, "hello")
|
||||
pressImeEnter()
|
||||
poll(what = "a line break after `hello` in the message field") { editableText(Ui.Chat.Message) == "hello\n" }
|
||||
check(chat.itemTexts().none { "hello" in it }) { "Enter sent the message: ${chat.itemTexts()}" }
|
||||
chat.send("there")
|
||||
bob.expectsMessage("alice> hello", CONNECT_TIMEOUT_MS)
|
||||
bob expects "there"
|
||||
}
|
||||
|
||||
@Test fun compose_replyAndEdit_showKeyboard() = uiTest {
|
||||
val alice = app("alice"); val bob = cli("bob")
|
||||
connect(bob)
|
||||
val chat = alice.chatList.open("bob")
|
||||
chat.send(MINE)
|
||||
bob.expectsMessage("alice> $MINE", CONNECT_TIMEOUT_MS)
|
||||
bob sends "@alice $QUESTION"
|
||||
val question = chat.itemId(QUESTION)
|
||||
hideKeyboard()
|
||||
rightClick(Ui.Chat.Item(question))
|
||||
waitFor(Ui.Chat.ItemMenu(question, "reply"))
|
||||
check(!keyboardShown() && !exists(CANCEL_CONTEXT)) { "the soft keyboard or a compose context is open before Reply" }
|
||||
click(Ui.Chat.ItemMenu(question, "reply"))
|
||||
poll(what = "the soft keyboard to open for the reply") { keyboardShown() && exists(CANCEL_CONTEXT) }
|
||||
val keyboardTop = settledKeyboardTop()
|
||||
val field = boundsInWindow(Ui.Chat.Message)
|
||||
val quoted = boundsInWindow(Ui.Chat.Item(question))
|
||||
check(field.bottom <= keyboardTop && quoted.bottom <= keyboardTop) {
|
||||
"with the keyboard open (top at $keyboardTop px) the message field is at $field and bob's message at $quoted"
|
||||
}
|
||||
|
||||
hideKeyboard()
|
||||
chat.menu(MINE, "edit")
|
||||
poll(what = "the soft keyboard to open for editing `$MINE`") { keyboardShown() && editableText(Ui.Chat.Message) == MINE }
|
||||
}
|
||||
|
||||
@Test fun compose_editText_clearButtonForWrappedParagraph() = uiTest {
|
||||
val alice = app("alice")
|
||||
alice.chatList.open(NOTES)
|
||||
typeInto(Ui.Chat.Message, "short")
|
||||
poll(what = "`short` on one line of the message field") { messageLineCount() == 1 }
|
||||
settle()
|
||||
check(!exists(Ui.Chat.ClearText)) { "the clear-text button is shown for one line" }
|
||||
replaceText(Ui.Chat.Message, PARAGRAPH)
|
||||
poll(what = "the paragraph wrapped to four lines or more, with the clear-text button") { messageLineCount() >= 4 && exists(Ui.Chat.ClearText) }
|
||||
check(editableText(Ui.Chat.Message)?.contains('\n') == false) { "the paragraph has a line break" }
|
||||
hideKeyboard()
|
||||
click(Ui.Chat.ClearText)
|
||||
poll(what = "the message field cleared and the button gone") { editableText(Ui.Chat.Message).isNullOrEmpty() && !exists(Ui.Chat.ClearText) }
|
||||
}
|
||||
|
||||
@Test fun attachSheet_keyboardOpen_fullyExpanded() = uiTest {
|
||||
val alice = app("alice")
|
||||
alice.chatList.open(NOTES)
|
||||
click(Ui.Chat.Message)
|
||||
poll(what = "the soft keyboard to open for the message field") { keyboardShown() }
|
||||
check(!seesDescription("Close sheet")) { "the attachment sheet is open before Attach" }
|
||||
click(described("Attach"))
|
||||
poll(what = "the attachment sheet with Camera, Image, Video and File") { seesDescription("Close sheet") && SHEET_BUTTONS.all { exists(it) } }
|
||||
SHEET_BUTTONS.forEach { waitStable(it) }
|
||||
val keyboardTop = settledKeyboardTop()
|
||||
val bottoms = SHEET_BUTTONS.associateWith { boundsInWindow(it).bottom }
|
||||
check(bottoms.values.all { it <= keyboardTop }) { "attachment buttons end at $bottoms, below the keyboard top at $keyboardTop px" }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NOTES = "Private notes"
|
||||
const val MINE = "my message to edit"
|
||||
const val QUESTION = "question to reply to"
|
||||
val PARAGRAPH = "word ".repeat(60).trim()
|
||||
val SHEET_BUTTONS = listOf("camera", "image", "video", "file").map { Ui.Chat.Attach(it) }
|
||||
val CANCEL_CONTEXT = described("Cancel")
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import chat.simplex.app.ui.harness.DB_WORK_TIMEOUT_MS
|
||||
import chat.simplex.app.ui.harness.UiTest
|
||||
import chat.simplex.app.ui.harness.backToChatList
|
||||
import chat.simplex.app.ui.harness.chatData
|
||||
import chat.simplex.app.ui.harness.onboarding
|
||||
import chat.simplex.app.ui.harness.typeSecret
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.chatCloseStore
|
||||
import chat.simplex.common.platform.chatController
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.platform.initChatController
|
||||
import chat.simplex.common.ui.Ui
|
||||
import chat.simplex.common.views.database.deleteChatDatabaseFilesAndState
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.DBMigrationResult
|
||||
import chat.simplex.common.views.helpers.DatabaseUtils
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
import java.security.KeyStore
|
||||
|
||||
class KeystoreAndroidTest : UiTest() {
|
||||
@Test fun keystore_freshDb_randomPassphraseFooter() = uiTest {
|
||||
val alice = app(null)
|
||||
freshInstallDatabaseViaApi()
|
||||
val randomKey = DatabaseUtils.ksDatabasePassword.get()
|
||||
check(!randomKey.isNullOrEmpty() && chatModel.chatDbEncrypted.value == true && storedSetting("InitialRandomDBPassphrase") == "true") {
|
||||
"the fresh database: key in the Keystore ${!randomKey.isNullOrEmpty()}, encrypted ${chatModel.chatDbEncrypted.value}, random ${storedSetting("InitialRandomDBPassphrase")}"
|
||||
}
|
||||
val o = onboarding()
|
||||
o.createProfile("alice")
|
||||
check(o.stage == OnboardingStage.Step3_ChooseServerOperators) { "the step after Create profile is ${o.stage}, not Your network" }
|
||||
click(Ui.Onboarding.Continue)
|
||||
poll(what = "Network commitments") { o.stage == OnboardingStage.Step4_NetworkCommitments && exists(Ui.Onboarding.Accept) }
|
||||
click(Ui.Onboarding.Accept)
|
||||
o.waitForChatList()
|
||||
check(DatabaseUtils.ksDatabasePassword.get() == randomKey && storedSetting("StoreDBPassphrase") == "true") { "onboarding changed the random key or stopped storing it" }
|
||||
|
||||
chatData(alice).management().passphrase()
|
||||
val stored = storedFooter()
|
||||
poll(what = "the random-passphrase footers") { seesText(stored) && seesText(RANDOM) }
|
||||
check(toggleState(Ui.DbPassphrase.Save) == ToggleableState.On && !isEnabled(Ui.DbPassphrase.Save)) {
|
||||
"Save passphrase in Keystore: ${toggleState(Ui.DbPassphrase.Save)}, enabled ${isEnabled(Ui.DbPassphrase.Save)}"
|
||||
}
|
||||
check(!exists(Ui.DbPassphrase.Current) && !seesText(NO_RECOVERY, substring = true)) { "the random passphrase screen asks for the current passphrase or shows the no-recovery note" }
|
||||
|
||||
typeSecret(Ui.DbPassphrase.New, NEW)
|
||||
typeSecret(Ui.DbPassphrase.Confirm, NEW)
|
||||
hideKeyboard()
|
||||
click(Ui.DbPassphrase.Update)
|
||||
poll(what = "the Change database passphrase? alert naming the Keystore") {
|
||||
seesText("Change database passphrase?") && seesText(WILL_STORE, substring = true) && seesText(STORE_SECURELY, substring = true) &&
|
||||
"Update" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared))
|
||||
}
|
||||
click(Ui.Alert.Confirm(Ui.AlertSource.Shared))
|
||||
poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the Database encrypted! alert") { seesText("Database encrypted!") }
|
||||
click(Ui.Alert.Confirm(Ui.AlertSource.Shared))
|
||||
poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the chat running again and the Current passphrase field") {
|
||||
!AlertManager.shared.hasAlertsShown() && chatModel.chatRunning.value == true && exists(Ui.DbPassphrase.Current)
|
||||
}
|
||||
poll(what = "the stored-passphrase footers without the random one") { seesText(stored) && seesText(NO_RECOVERY, substring = true) && !seesText(RANDOM) }
|
||||
check(toggleState(Ui.DbPassphrase.Save) == ToggleableState.On && isEnabled(Ui.DbPassphrase.Save)) {
|
||||
"Save passphrase in Keystore after the change: ${toggleState(Ui.DbPassphrase.Save)}, enabled ${isEnabled(Ui.DbPassphrase.Save)}"
|
||||
}
|
||||
check(DatabaseUtils.ksDatabasePassword.get() == NEW && storedSetting("InitialRandomDBPassphrase") == "false") {
|
||||
"after the change: Keystore holds the new passphrase ${DatabaseUtils.ksDatabasePassword.get() == NEW}, random ${storedSetting("InitialRandomDBPassphrase")}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun keystore_keyMissing_showsNotFoundAlert() = uiTest {
|
||||
val alice = app("alice")
|
||||
val notes = alice.chatList.open(NOTES)
|
||||
notes.send(NOTE)
|
||||
notes.itemId(NOTE)
|
||||
backToChatList()
|
||||
encryptDatabaseViaApi(NEW)
|
||||
check(DatabaseUtils.ksDatabasePassword.get() == NEW && storedSetting("StoreDBPassphrase") == "true") { "the passphrase is not stored in the Keystore before the key is lost" }
|
||||
deleteKeystoreKeyViaApi()
|
||||
check(storedSetting("EncryptedDBPassphrase") != null && !seesText(NOT_FOUND)) { "the encrypted passphrase is gone from the prefs, or the alert shows before the restart" }
|
||||
val after = restart(ready = { chatModel.chatDbStatus.value is DBMigrationResult.ErrorNotADatabase && !chatModel.ctrlInitInProgress.value }, what = "the database left locked without its key")
|
||||
poll(what = "the Wrong database passphrase alert saying the passphrase is not in the Keystore") {
|
||||
AlertManager.shared.hasAlertsShown() && seesText("Wrong database passphrase") && seesText(NOT_FOUND)
|
||||
}
|
||||
click(Ui.Alert.Confirm(Ui.AlertSource.Shared))
|
||||
poll(what = "the Encrypted database prompt with Save passphrase and open chat") {
|
||||
!AlertManager.shared.hasAlertsShown() && seesText("Encrypted database") && "Save passphrase and open chat" in textsOf(Ui.DbError.Open)
|
||||
}
|
||||
typeSecret(Ui.DbError.Passphrase, NEW)
|
||||
pressEnter(Ui.DbError.Passphrase)
|
||||
poll(timeoutMs = DB_WORK_TIMEOUT_MS, what = "the chat list with alice's note after the keyboard's Done key") {
|
||||
chatModel.chatDbStatus.value == DBMigrationResult.OK && chatModel.chatRunning.value == true && NOTE in after.chatList.rowTexts(NOTES)
|
||||
}
|
||||
check(DatabaseUtils.ksDatabasePassword.get() == NEW) { "Save passphrase and open chat did not store the passphrase in the Keystore again" }
|
||||
}
|
||||
|
||||
private fun freshInstallDatabaseViaApi() {
|
||||
val ctrl = chatController.getChatCtrl()
|
||||
if (ctrl != null && ctrl != -1L) chatCloseStore(ctrl).let { check(it.isEmpty()) { "chatCloseStore failed: $it" } }
|
||||
chatController.setChatCtrl(null)
|
||||
deleteChatDatabaseFilesAndState()
|
||||
appPrefs.initialRandomDBPassphrase.set(false)
|
||||
runBlocking { initChatController() }
|
||||
check(chatModel.chatDbStatus.value == DBMigrationResult.OK) { "fresh database: ${chatModel.chatDbStatus.value}" }
|
||||
}
|
||||
|
||||
private fun encryptDatabaseViaApi(passphrase: String) {
|
||||
val current = DatabaseUtils.ksDatabasePassword.get() ?: ""
|
||||
val error = runBlocking {
|
||||
chatController.apiStopChat()
|
||||
chatModel.chatRunning.value = false
|
||||
chatController.apiStorageEncryption(currentKey = current, newKey = passphrase)
|
||||
}
|
||||
check(error == null) { "encrypting the database failed: $error" }
|
||||
DatabaseUtils.ksDatabasePassword.set(passphrase)
|
||||
appPrefs.initialRandomDBPassphrase.set(false)
|
||||
chatModel.chatDbEncrypted.value = true
|
||||
}
|
||||
|
||||
private fun deleteKeystoreKeyViaApi() {
|
||||
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
ks.deleteEntry(DB_KEY_ALIAS)
|
||||
check(!ks.containsAlias(DB_KEY_ALIAS)) { "the Keystore still holds $DB_KEY_ALIAS" }
|
||||
}
|
||||
|
||||
private fun storedFooter(): String =
|
||||
"Android Keystore${DatabaseUtils.ksDatabasePassword.storage()?.let { " ($it)" } ?: ""} is used to securely store passphrase - it allows notification service to work."
|
||||
|
||||
private companion object {
|
||||
const val NEW = "uitest-keystore-5Hq"
|
||||
const val NOTES = "Private notes"
|
||||
const val NOTE = "written before the key was lost"
|
||||
const val DB_KEY_ALIAS = "databasePassword"
|
||||
const val RANDOM = "Database is encrypted using a random passphrase, you can change it."
|
||||
const val NO_RECOVERY = "you will NOT be able to recover or change passphrase if you lose it."
|
||||
const val WILL_STORE = "Database encryption passphrase will be updated and stored in the Keystore."
|
||||
const val STORE_SECURELY = "Please store passphrase securely, you will NOT be able to change it if you lose it."
|
||||
const val NOT_FOUND = "Passphrase not found in Keystore, please enter it manually. This may have happened if you restored the app's data using a backup tool. If it's not the case, please contact developers."
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.content.pm.PackageManager
|
||||
import chat.simplex.app.SimplexService
|
||||
import chat.simplex.app.ui.harness.App
|
||||
import chat.simplex.app.ui.harness.UiScope
|
||||
import chat.simplex.app.ui.harness.UiTest
|
||||
import chat.simplex.app.ui.harness.described
|
||||
import chat.simplex.common.model.NotificationsMode
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.ui.Ui
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import org.junit.Test
|
||||
|
||||
class NotificationModesAndroidTest : UiTest() {
|
||||
@Test fun ntfMode_switchToService_startsForegroundService() = uiTest {
|
||||
val alice = app("alice")
|
||||
requireNotificationPermission()
|
||||
withBackgroundServiceAllowed {
|
||||
openNotificationSettings(alice)
|
||||
check(OFF_TITLE in textsOf(Ui.Notifications.Mode)) { "Notification service row: ${textsOf(Ui.Notifications.Mode)}" }
|
||||
openModePicker()
|
||||
check(MODES.all { (mode, title) -> title in textsOf(option(mode)) }) { "mode options: ${MODES.map { textsOf(option(it.first)) }}" }
|
||||
check(checkedTitles() == listOf(OFF_TITLE) && seesText(OFF_DESC)) { "picker before the change: checked ${checkedTitles()}" }
|
||||
check(serviceNotification() == null && receiverState(START_RECEIVER) != PackageManager.COMPONENT_ENABLED_STATE_ENABLED && !uniqueWorkActive(RESTART_WORK)) {
|
||||
"before Always on: service notification ${serviceNotification() != null}, StartReceiver ${receiverState(START_RECEIVER)}, restart work ${workStates(RESTART_WORK)}"
|
||||
}
|
||||
|
||||
click(option(NotificationsMode.SERVICE))
|
||||
poll(what = "Always on checked and stored, with its description") {
|
||||
storedSetting(MODE_KEY) == NotificationsMode.SERVICE.name && checkedTitles() == listOf(SERVICE_TITLE) && seesText(SERVICE_DESC)
|
||||
}
|
||||
poll(what = "the SimpleX Chat service notification with Hide") {
|
||||
serviceNotification()?.let { n ->
|
||||
n.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() == "SimpleX Chat service" &&
|
||||
n.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() == "Receiving messages…" &&
|
||||
n.channelId == SimplexService.NOTIFICATION_CHANNEL_ID && n.actions.orEmpty().any { it.title?.toString() == "Hide" }
|
||||
} == true && SimplexService.isServiceStarted
|
||||
}
|
||||
poll(what = "the boot and update receivers enabled and the restart worker scheduled") {
|
||||
receiverState(START_RECEIVER) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED &&
|
||||
receiverState(UPDATE_RECEIVER) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED && uniqueWorkActive(RESTART_WORK)
|
||||
}
|
||||
check(!AlertManager.shared.hasAlertsShown()) { "switching to Always on while allowed in background showed an alert: ${texts().take(20)}" }
|
||||
escape()
|
||||
poll(what = "Notifications showing Always on") { exists(Ui.Notifications.Mode) && SERVICE_TITLE in textsOf(Ui.Notifications.Mode) }
|
||||
|
||||
openModePicker()
|
||||
click(option(NotificationsMode.OFF))
|
||||
poll(what = "Runs when app is open checked and stored") { storedSetting(MODE_KEY) == NotificationsMode.OFF.name && checkedTitles() == listOf(OFF_TITLE) }
|
||||
poll(what = "the service stopped, its notification gone, the receivers disabled and the restart worker cancelled") {
|
||||
serviceNotification() == null && !SimplexService.isServiceStarted &&
|
||||
receiverState(START_RECEIVER) == PackageManager.COMPONENT_ENABLED_STATE_DISABLED &&
|
||||
receiverState(UPDATE_RECEIVER) == PackageManager.COMPONENT_ENABLED_STATE_DISABLED && !uniqueWorkActive(RESTART_WORK)
|
||||
}
|
||||
check(!AlertManager.shared.hasAlertsShown()) { "switching to Runs when app is open showed an alert: ${texts().take(20)}" }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun ntfMode_switchToPeriodic_schedulesFetcher() = uiTest {
|
||||
val alice = app("alice")
|
||||
requireNotificationPermission()
|
||||
withBackgroundServiceAllowed {
|
||||
val fetcher = "${androidAppContext.packageName}.UNIQUE_MESSAGES_FETCHER"
|
||||
openNotificationSettings(alice)
|
||||
openModePicker()
|
||||
check(checkedTitles() == listOf(OFF_TITLE) && !uniqueWorkActive(fetcher)) { "before Starts periodically: checked ${checkedTitles()}, fetcher ${workStates(fetcher)}" }
|
||||
|
||||
click(option(NotificationsMode.PERIODIC))
|
||||
poll(what = "Starts periodically checked and stored, with its description") {
|
||||
storedSetting(MODE_KEY) == NotificationsMode.PERIODIC.name && checkedTitles() == listOf(PERIODIC_TITLE) && seesText(PERIODIC_DESC)
|
||||
}
|
||||
poll(what = "the periodic message fetcher scheduled") { uniqueWorkActive(fetcher) }
|
||||
check(serviceNotification() == null && !SimplexService.isServiceStarted && receiverState(START_RECEIVER) == PackageManager.COMPONENT_ENABLED_STATE_DISABLED) {
|
||||
"Starts periodically: service notification ${serviceNotification() != null}, service started ${SimplexService.isServiceStarted}, StartReceiver ${receiverState(START_RECEIVER)}"
|
||||
}
|
||||
check(!uniqueWorkActive(RESTART_WORK) && !AlertManager.shared.hasAlertsShown()) { "Starts periodically: restart work ${workStates(RESTART_WORK)}, alert ${AlertManager.shared.hasAlertsShown()}" }
|
||||
escape()
|
||||
poll(what = "Notifications showing Starts periodically") { exists(Ui.Notifications.Mode) && PERIODIC_TITLE in textsOf(Ui.Notifications.Mode) }
|
||||
|
||||
openModePicker()
|
||||
click(option(NotificationsMode.OFF))
|
||||
poll(what = "the periodic message fetcher cancelled with Runs when app is open stored") {
|
||||
storedSetting(MODE_KEY) == NotificationsMode.OFF.name && !uniqueWorkActive(fetcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireNotificationPermission() =
|
||||
check(androidAppContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
"the runner must grant POST_NOTIFICATIONS (setup item 2): without it the service does not start"
|
||||
}
|
||||
|
||||
private fun UiScope.openNotificationSettings(app: App) {
|
||||
app.settings()
|
||||
click(described("Notifications"))
|
||||
waitFor(Ui.Notifications.Mode)
|
||||
}
|
||||
|
||||
private fun UiScope.openModePicker() {
|
||||
click(Ui.Notifications.Mode)
|
||||
waitFor(option(NotificationsMode.SERVICE))
|
||||
}
|
||||
|
||||
private fun UiScope.checkedTitles(): List<String> = MODES.map { it.second }.filter { seesDescription(it) }
|
||||
|
||||
private fun option(mode: NotificationsMode) = Ui.Notifications.Mode.option(mode)
|
||||
|
||||
private companion object {
|
||||
const val MODE_KEY = "NotificationsMode"
|
||||
const val OFF_TITLE = "Runs when app is open"
|
||||
const val PERIODIC_TITLE = "Starts periodically"
|
||||
const val SERVICE_TITLE = "Always on"
|
||||
const val OFF_DESC = "App can receive notifications only when it's running, no background service will be started"
|
||||
const val PERIODIC_DESC = "Checks new messages every 10 minutes for up to 1 minute"
|
||||
const val SERVICE_DESC = "Background service is always running – notifications will be shown as soon as the messages are available."
|
||||
const val RESTART_WORK = SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC
|
||||
val MODES = listOf(NotificationsMode.OFF to OFF_TITLE, NotificationsMode.PERIODIC to PERIODIC_TITLE, NotificationsMode.SERVICE to SERVICE_TITLE)
|
||||
val START_RECEIVER = SimplexService.StartReceiver::class.java
|
||||
val UPDATE_RECEIVER = SimplexService.AppUpdateReceiver::class.java
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import chat.simplex.app.ui.harness.UiTest
|
||||
import chat.simplex.app.ui.harness.onboarding
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.Ui
|
||||
import chat.simplex.common.views.migration.MigrationToState
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import org.junit.Test
|
||||
|
||||
class OnboardingProfileAndroidTest : UiTest() {
|
||||
@Test fun androidOnboardingCreateProfileMobileLayout() = uiTest {
|
||||
app(null)
|
||||
waitFor(Ui.Onboarding.GetStarted)
|
||||
check(!keyboardShown()) { "the soft keyboard is open on the welcome screen" }
|
||||
click(Ui.Onboarding.GetStarted)
|
||||
poll(what = "Your profile with the name field focused and the soft keyboard open") {
|
||||
onboarding().stage == OnboardingStage.Step2_CreateProfile && exists(Ui.Onboarding.ProfileName) && keyboardShown()
|
||||
}
|
||||
check(seesText("Your profile") && seesText("On your phone, not on servers.") && seesText(NO_ACCOUNT, substring = true) && seesText("Enter profile name…")) {
|
||||
"the mobile profile screen: ${texts().take(30)}"
|
||||
}
|
||||
check(textsOf(Ui.Onboarding.Migrate).toSet() == setOf("Migrate")) { "migrate button: ${textsOf(Ui.Onboarding.Migrate)}" }
|
||||
check("Create profile" in textsOf(Ui.Onboarding.CreateProfile) && !isEnabled(Ui.Onboarding.CreateProfile)) { "Create profile is enabled with an empty name" }
|
||||
typeInto(Ui.Onboarding.ProfileName, "alice")
|
||||
poll(what = "Create profile enabled for `alice`") { isEnabled(Ui.Onboarding.CreateProfile) }
|
||||
|
||||
click(Ui.Onboarding.Migrate)
|
||||
poll(what = "Migrate here leading with the camera section, then the archive link and file") {
|
||||
chatModel.migrationState.value is MigrationToState.PasteOrScanLink && seesText("Scan QR code") && seesText("Or paste archive link") && seesText("Or import archive file")
|
||||
}
|
||||
check(!seesText("Paste archive link") && chatModel.currentUser.value == null) { "the desktop paste label is shown, or a profile was created: ${texts().take(30)}" }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NO_ACCOUNT = "No account. No phone. No email. No ID."
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import chat.simplex.app.ui.harness.UiTest
|
||||
import chat.simplex.app.ui.harness.chatRunningInCore
|
||||
import chat.simplex.app.ui.harness.described
|
||||
import chat.simplex.app.ui.harness.onboarding
|
||||
import chat.simplex.app.ui.harness.openProfileMenu
|
||||
import chat.simplex.app.ui.harness.userIdOf
|
||||
import chat.simplex.app.BuildConfig
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.Ui
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.ModalManager
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import org.junit.Test
|
||||
|
||||
class SettingsAndroidTest : UiTest() {
|
||||
@Test fun settingsAndroid_rootListsAndroidItems() = uiTest {
|
||||
val alice = app("alice")
|
||||
alice.settings()
|
||||
waitFor(Ui.Settings.Version)
|
||||
val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
|
||||
poll(what = "Your settings with the Android rows and the Android version") { ROOT_ITEMS.all { seesText(it) } && version in textsOf(Ui.Settings.Version) }
|
||||
val texts = texts()
|
||||
val advanced = ADVANCED_ORDER.map { texts.indexOf(it) }
|
||||
check(advanced.zipWithNext().all { (a, b) -> a in 0 until b } && texts.indexOf(version) > advanced.last()) {
|
||||
"Advanced settings order ${ADVANCED_ORDER.zip(advanced)}, version at ${texts.indexOf(version)}"
|
||||
}
|
||||
|
||||
click(described("Shutdown"))
|
||||
poll(what = "the Shutdown? alert with Cancel, Restart and Shutdown") {
|
||||
AlertManager.shared.hasAlertsShown() && seesText("Shutdown?") && seesText(SHUTDOWN_DESC) && seesText("Restart") &&
|
||||
"Cancel" in textsOf(Ui.Alert.Dismiss(Ui.AlertSource.Shared))
|
||||
}
|
||||
click(Ui.Alert.Dismiss(Ui.AlertSource.Shared))
|
||||
poll(what = "the alert closed over Your settings") { !AlertManager.shared.hasAlertsShown() && exists(Ui.Settings.Version) }
|
||||
check(chatModel.chatRunning.value == true && chatRunningInCore()) { "Cancel on the Shutdown? alert stopped the chat" }
|
||||
|
||||
click(Ui.Settings.Version)
|
||||
poll(what = "App version with the Android version, build and core version") {
|
||||
seesText("App version: v${BuildConfig.VERSION_NAME}") && seesText("App build: ${BuildConfig.VERSION_CODE}") &&
|
||||
seesText("Core version: v", substring = true) && seesText("Developer tools")
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun helpAndroid_rateTheAppOpensMarket() = uiTest {
|
||||
val alice = app("alice")
|
||||
alice.settings()
|
||||
click(Ui.Settings.Help)
|
||||
waitFor(RATE)
|
||||
check(seesText("Rate the app") && seesText("Contribute") && seesText("Star on GitHub")) { "Support the project rows: ${texts().take(40)}" }
|
||||
check(viewIntents().isEmpty()) { "an ACTION_VIEW intent was fired before Rate the app: ${viewIntents()}" }
|
||||
click(RATE)
|
||||
poll(what = "an ACTION_VIEW intent for SimpleX Chat in the app store") { viewIntents().isNotEmpty() }
|
||||
check(viewIntents() == listOf(MARKET_URI)) { "Rate the app opened ${viewIntents()}" }
|
||||
}
|
||||
|
||||
@Test fun profilesAndroid_deleteLastVisibleReturnsToOnboarding() = uiTest {
|
||||
val alice = app("alice")
|
||||
val aliceId = userIdOf("alice")
|
||||
alice.userPicker().profiles()
|
||||
waitFor(Ui.Profiles.User(aliceId))
|
||||
check(chatModel.users.size == 1 && chatRunningInCore()) { "users before the deletion: ${chatModel.users.map { it.user.displayName }}" }
|
||||
openProfileMenu(aliceId, "delete")
|
||||
click(Ui.Profiles.UserMenu(aliceId, "delete"))
|
||||
val withConnections = Ui.Alert.Option(Ui.AlertSource.Shared, "deleteWithConnections")
|
||||
poll(what = "the `Delete chat profile?` alert with `Profile and server connections`") { seesText("Delete chat profile?") && exists(withConnections) }
|
||||
click(withConnections)
|
||||
poll(what = "the welcome screen with Get started in place of the profiles list") {
|
||||
exists(Ui.Onboarding.GetStarted) && onboarding().stage == OnboardingStage.Step1_SimpleXInfo && !ModalManager.start.hasModalsOpen() && !exists(Ui.Profiles.Add)
|
||||
}
|
||||
check(chatModel.currentUser.value == null && chatModel.users.isEmpty() && !chatRunningInCore()) {
|
||||
"after deleting the last profile: current ${chatModel.currentUser.value?.displayName}, users ${chatModel.users.size}, chat running ${chatRunningInCore()}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun viewIntents(): List<String> =
|
||||
Intents.getIntents().filter { it.action == Intent.ACTION_VIEW }.map { it.data.toString() }
|
||||
|
||||
private companion object {
|
||||
val RATE = described("Google Play")
|
||||
const val MARKET_URI = "market://details?id=chat.simplex.app"
|
||||
const val SHUTDOWN_DESC = "Notifications will stop working until you re-launch the app"
|
||||
val ROOT_ITEMS = listOf(
|
||||
"Your settings", "Appearance", "Your privacy", "Help & support", "Chat data", "Migrate to another device", "Supporter perks",
|
||||
"Network & servers", "Notifications", "Audio & video calls", "Shutdown", "Crowdfunding on Wefunder"
|
||||
)
|
||||
val ADVANCED_ORDER = listOf("Network & servers", "Notifications", "Audio & video calls", "Shutdown")
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import chat.simplex.app.ui.back.backFromRoot
|
||||
import chat.simplex.app.ui.back.relaunch
|
||||
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
|
||||
import chat.simplex.app.ui.harness.ChatScreen
|
||||
import chat.simplex.app.ui.harness.UiScope
|
||||
import chat.simplex.app.ui.harness.UiTest
|
||||
import chat.simplex.app.ui.harness.connect
|
||||
import chat.simplex.app.ui.harness.described
|
||||
import chat.simplex.app.ui.harness.jpegFile
|
||||
import chat.simplex.app.ui.harness.receivesCompressedImage
|
||||
import chat.simplex.app.ui.harness.receivesFile
|
||||
import chat.simplex.common.model.ChatInfo
|
||||
import chat.simplex.common.model.GroupFeatureEnabled
|
||||
import chat.simplex.common.model.GroupPreferences
|
||||
import chat.simplex.common.model.GroupProfile
|
||||
import chat.simplex.common.model.RoleGroupPreference
|
||||
import chat.simplex.common.platform.chatController
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.Ui
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.ModalManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class ShareIntoAppAndroidTest : UiTest() {
|
||||
@Test fun shareIn_text_toContact() = uiTest {
|
||||
val alice = app("alice"); val bob = cli("bob")
|
||||
connect(bob)
|
||||
val bobId = alice.chatList.chatId("bob")
|
||||
val chat = alice.chatList.open("bob")
|
||||
chat.info()
|
||||
waitFor(Ui.Info.Header)
|
||||
check(ModalManager.end.hasModalsOpen() && shareListTitle() == null) { "bob's contact info is not open over his chat before the share" }
|
||||
deliverToApp(shareTextIntent(TEXT))
|
||||
poll(what = "the share list titled `$SHARE_MESSAGE` in place of bob's chat and contact info") {
|
||||
shareListTitle() == SHARE_MESSAGE && chatModel.chatId.value == null && !ModalManager.end.hasModalsOpen() && !exists(Ui.Info.Header) && exists(Ui.Share.Row(bobId))
|
||||
}
|
||||
alice.chatList.shareTo("bob")
|
||||
poll(what = "bob's chat with the shared text in the message field") { chatModel.chatId.value == bobId && editableText(Ui.Chat.Message) == TEXT }
|
||||
chat.sendComposed()
|
||||
bob.expectsMessage("alice> $TEXT", CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
@Test fun shareIn_file_toContact() = uiTest {
|
||||
val alice = app("alice"); val bob = cli("bob")
|
||||
connect(bob)
|
||||
val bobId = alice.chatList.chatId("bob")
|
||||
val pdf = File(exchangeDir(), PDF_NAME).apply { writeText(PDF_CONTENT) }
|
||||
deliverToApp(shareStreamIntent("application/pdf", contentUri(pdf), CAPTION))
|
||||
awaitShareList(SHARE_FILE)
|
||||
alice.chatList.shareTo("bob")
|
||||
poll(what = "bob's chat with the preview of $PDF_NAME and the shared caption") {
|
||||
chatModel.chatId.value == bobId && seesDescription("Cancel file preview") && seesText(PDF_NAME) && editableText(Ui.Chat.Message) == CAPTION
|
||||
}
|
||||
ChatScreen(this).sendComposed()
|
||||
poll(what = "the file preview cleared after Send") { !seesDescription("Cancel file preview") }
|
||||
bob.expectsMessage("alice> $CAPTION", CONNECT_TIMEOUT_MS)
|
||||
bob expectsMessage "alice> sends file $PDF_NAME (${PDF_CONTENT.length} bytes / ${PDF_CONTENT.length} bytes)"
|
||||
bob expects "use /fr 1 [<dir>/ | <path>] to receive it"
|
||||
bob.receivesFile(1, PDF_NAME, "alice")
|
||||
}
|
||||
|
||||
@Test fun shareIn_image_toContact() = uiTest {
|
||||
val alice = app("alice"); val bob = cli("bob")
|
||||
connect(bob)
|
||||
val bobId = alice.chatList.chatId("bob")
|
||||
val (one, two, three) = listOf("share-1.jpg", "share-2.jpg", "share-3.jpg").map { contentUri(jpegFile(it)) }
|
||||
deliverToApp(shareStreamIntent("image/jpeg", one))
|
||||
awaitShareList(SHARE_MEDIA)
|
||||
alice.chatList.shareTo("bob")
|
||||
poll(what = "bob's chat with one image preview") { chatModel.chatId.value == bobId && seesDescription("Cancel image preview") && previewImages() == 1 }
|
||||
sendImages()
|
||||
bob.receivesCompressedImage("alice> ", 1)
|
||||
|
||||
deliverToApp(shareStreamsIntent("image/*", listOf(two, three)))
|
||||
awaitShareList(SHARE_MEDIA)
|
||||
alice.chatList.shareTo("bob")
|
||||
poll(what = "bob's chat with two image previews") { chatModel.chatId.value == bobId && seesDescription("Cancel image preview") && previewImages() == 2 }
|
||||
sendImages()
|
||||
bob.receivesCompressedImage("alice> ", 2)
|
||||
bob.receivesCompressedImage("alice> ", 3)
|
||||
}
|
||||
|
||||
@Test fun shareIn_fileSchemeUri_invalidPathAlert() = uiTest {
|
||||
app("alice").chatList.row(NOTES)
|
||||
val fileUri = Uri.parse("file:///sdcard/Download/shared.jpg")
|
||||
val intents = listOf(
|
||||
shareStreamIntent("image/jpeg", fileUri),
|
||||
shareStreamsIntent("image/*", listOf(contentUri(jpegFile("share-ok.jpg")), fileUri)),
|
||||
shareStreamIntent("text/plain", fileUri, TEXT),
|
||||
shareStreamIntent("application/pdf", fileUri),
|
||||
)
|
||||
for (intent in intents) {
|
||||
val what = "${intent.action} ${intent.type}"
|
||||
check(!AlertManager.shared.hasAlertsShown() && shareListTitle() == null) { "an alert or the share list is shown before $what" }
|
||||
deliverToApp(intent)
|
||||
poll(what = "the Invalid file path alert for $what") { seesText("Invalid file path") && seesText(INVALID_PATH) }
|
||||
check(shareListTitle() == null && chatModel.sharedContent.value == null) { "$what with a file: URI was shared: ${chatModel.sharedContent.value}" }
|
||||
click(Ui.Alert.Confirm(Ui.AlertSource.Shared))
|
||||
poll(what = "the alert closed over the chat list after $what") { !AlertManager.shared.hasAlertsShown() && exists(Ui.ChatList.Search) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun shareIn_sendMultipleNonMedia_onlyClosesChat() = uiTest {
|
||||
val alice = app("alice")
|
||||
val notesId = alice.chatList.chatId(NOTES)
|
||||
alice.chatList.open(NOTES)
|
||||
waitFor(Ui.Chat.Message)
|
||||
check(chatModel.chatId.value == notesId) { "Private notes did not open" }
|
||||
val pdf = contentUri(File(exchangeDir(), PDF_NAME).apply { writeText(PDF_CONTENT) })
|
||||
deliverToApp(shareStreamsIntent("application/pdf", listOf(pdf, pdf)))
|
||||
poll(what = "Private notes closed") { chatModel.chatId.value == null }
|
||||
settle()
|
||||
check(shareListTitle() == null && exists(Ui.ChatList.Search) && chatModel.sharedContent.value == null && !AlertManager.shared.hasAlertsShown()) {
|
||||
"two shared PDFs: share list ${shareListTitle()}, shared content ${chatModel.sharedContent.value}, alert ${AlertManager.shared.hasAlertsShown()}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun shareIn_fileToGroupWithFilesOff_alert() = uiTest {
|
||||
app("alice").chatList.row(NOTES)
|
||||
newGroupViaApi(NO_FILES, GroupPreferences(files = RoleGroupPreference(GroupFeatureEnabled.OFF)))
|
||||
newGroupViaApi(NO_LINKS, GroupPreferences(simplexLinks = RoleGroupPreference(GroupFeatureEnabled.OFF)))
|
||||
val alice = restart()
|
||||
poll(what = "both groups loaded after the restart") { chatModel.chats.value.count { it.chatInfo is ChatInfo.Group } == 2 }
|
||||
val noFilesId = alice.chatList.chatId(NO_FILES)
|
||||
val noLinksId = alice.chatList.chatId(NO_LINKS)
|
||||
val linkText = "join me: ${invitationLinkViaApi()}"
|
||||
val pdf = File(exchangeDir(), PDF_NAME).apply { writeText(PDF_CONTENT) }
|
||||
|
||||
deliverToApp(shareStreamIntent("application/pdf", contentUri(pdf)))
|
||||
awaitShareList(SHARE_FILE)
|
||||
alice.chatList.shareTo(NO_FILES)
|
||||
awaitProhibitedAlert()
|
||||
alice.chatList.shareTo(NO_LINKS)
|
||||
poll(what = "$NO_LINKS open with the preview of $PDF_NAME") { chatModel.chatId.value == noLinksId && seesDescription("Cancel file preview") && seesText(PDF_NAME) }
|
||||
|
||||
deliverToApp(shareTextIntent(linkText))
|
||||
awaitShareList(SHARE_MESSAGE)
|
||||
alice.chatList.shareTo(NO_LINKS)
|
||||
awaitProhibitedAlert()
|
||||
alice.chatList.shareTo(NO_FILES)
|
||||
poll(what = "$NO_FILES open with the link in the message field") { chatModel.chatId.value == noFilesId && editableText(Ui.Chat.Message) == linkText }
|
||||
}
|
||||
|
||||
@Test fun shareIn_systemBack_dropsContentAndFinishes() = uiTest {
|
||||
app("alice").chatList.row(NOTES)
|
||||
deliverToApp(shareTextIntent(TEXT))
|
||||
awaitShareList(SHARE_MESSAGE)
|
||||
backFromRoot()
|
||||
check(chatModel.sharedContent.value == null) { "Back left the shared content: ${chatModel.sharedContent.value}" }
|
||||
relaunch {
|
||||
poll(what = "the chat list, not the share list, after relaunching") { exists(Ui.ChatList.Search) && shareListTitle() == null }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun shareIn_chatStopped_showsStoppedIcon() = uiTest {
|
||||
val alice = app("alice")
|
||||
val notesId = alice.chatList.chatId(NOTES)
|
||||
deliverToApp(shareTextIntent(TEXT))
|
||||
awaitShareList(SHARE_MESSAGE)
|
||||
check(!seesDescription(STOPPED) && rowTappable(notesId)) { "the share list of a running chat: stopped icon ${seesDescription(STOPPED)}, notes tappable ${rowTappable(notesId)}" }
|
||||
click(described("Back"))
|
||||
poll(what = "the chat list after the toolbar Back, with the shared text dropped") {
|
||||
shareListTitle() == null && exists(Ui.ChatList.Search) && chatModel.sharedContent.value == null
|
||||
}
|
||||
|
||||
stopChatFromChatData(alice)
|
||||
deliverToApp(shareTextIntent(TEXT))
|
||||
awaitShareList(SHARE_MESSAGE)
|
||||
check(seesDescription(STOPPED) && !rowTappable(notesId)) { "the share list of a stopped chat: stopped icon ${seesDescription(STOPPED)}, notes tappable ${rowTappable(notesId)}" }
|
||||
click(described(STOPPED))
|
||||
poll(what = "the `$STOPPED` alert with how to start the chat") { AlertManager.shared.hasAlertsShown() && seesText(STOPPED) && seesText(START_HINT) }
|
||||
click(Ui.Alert.Confirm(Ui.AlertSource.Shared))
|
||||
poll(what = "the alert closed over the share list") { !AlertManager.shared.hasAlertsShown() && shareListTitle() == SHARE_MESSAGE }
|
||||
}
|
||||
|
||||
private fun UiScope.previewImages(): Int =
|
||||
rule.onAllNodes(hasContentDescription("preview image"), useUnmergedTree = true).fetchSemanticsNodes().size
|
||||
|
||||
private fun UiScope.sendImages() {
|
||||
click(Ui.Chat.Send)
|
||||
poll(what = "the image previews cleared after Send") { !seesDescription("Cancel image preview") }
|
||||
}
|
||||
|
||||
private fun UiScope.awaitProhibitedAlert() {
|
||||
poll(what = "the `Cannot send message` alert") { seesText("Cannot send message") && seesText("Selected chat preferences prohibit this message.") }
|
||||
check(chatModel.chatId.value == null) { "a prohibited chat opened: ${chatModel.chatId.value}" }
|
||||
click(Ui.Alert.Confirm(Ui.AlertSource.Shared))
|
||||
poll(what = "the alert closed over the share list") { !AlertManager.shared.hasAlertsShown() && shareListTitle() != null }
|
||||
}
|
||||
|
||||
private fun UiScope.rowTappable(chatId: String): Boolean {
|
||||
waitFor(Ui.Share.Row(chatId))
|
||||
return rule.onAllNodes(hasTestTag(Ui.Share.Row(chatId).tag) and hasClickAction()).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
private fun newGroupViaApi(name: String, prefs: GroupPreferences) {
|
||||
runBlocking { chatController.apiNewGroup(null, incognito = false, GroupProfile(name, "", null, groupPreferences = prefs)) } ?: error("apiNewGroup($name) failed")
|
||||
}
|
||||
|
||||
private fun invitationLinkViaApi(): String =
|
||||
runBlocking { chatController.apiAddContact(null, incognito = false) }.first?.first?.simplexChatUri(short = false) ?: error("apiAddContact failed")
|
||||
|
||||
private companion object {
|
||||
const val NOTES = "Private notes"
|
||||
const val TEXT = "hello from share"
|
||||
const val CAPTION = "the notes, shared from another app"
|
||||
const val PDF_NAME = "share-notes.pdf"
|
||||
const val PDF_CONTENT = "Notes shared into SimpleX from another app"
|
||||
const val NO_FILES = "nofiles"
|
||||
const val NO_LINKS = "nolinks"
|
||||
const val SHARE_MESSAGE = "Share message…"
|
||||
const val SHARE_MEDIA = "Share media…"
|
||||
const val SHARE_FILE = "Share file…"
|
||||
const val STOPPED = "Chat is stopped"
|
||||
const val START_HINT = "You can start chat via app Settings / Database or by restarting the app."
|
||||
const val INVALID_PATH = "You shared an invalid file path. Report the issue to the app developers."
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
|
||||
import androidx.test.runner.lifecycle.Stage
|
||||
import chat.simplex.app.MainActivity
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.ui.harness.App
|
||||
import chat.simplex.app.ui.harness.UiScope
|
||||
import chat.simplex.app.ui.harness.UiTestApp
|
||||
import chat.simplex.app.ui.harness.chatData
|
||||
import chat.simplex.app.ui.harness.chatRunningInCore
|
||||
import chat.simplex.common.helpers.APPLICATION_ID
|
||||
import chat.simplex.common.platform.androidAppContext
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.platform.isAppOnForeground
|
||||
import chat.simplex.common.ui.Ui
|
||||
import java.io.File
|
||||
|
||||
internal fun UiScope.deliverToApp(intent: Intent) {
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.startActivity(
|
||||
Intent(intent).setClass(androidAppContext, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
internal fun contentUri(file: File): Uri = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", file)
|
||||
|
||||
internal fun shareTextIntent(text: String): Intent =
|
||||
Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text)
|
||||
|
||||
internal fun shareStreamIntent(type: String, uri: Uri, text: String? = null): Intent =
|
||||
Intent(Intent.ACTION_SEND).setType(type).putExtra(Intent.EXTRA_STREAM, uri).apply { if (text != null) putExtra(Intent.EXTRA_TEXT, text) }
|
||||
|
||||
internal fun shareStreamsIntent(type: String, uris: List<Uri>): Intent =
|
||||
Intent(Intent.ACTION_SEND_MULTIPLE).setType(type).putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
|
||||
|
||||
internal fun viewIntent(link: String?): Intent =
|
||||
Intent(Intent.ACTION_VIEW).apply { if (link != null) data = Uri.parse(link) }
|
||||
|
||||
internal fun UiScope.shareListTitle(): String? =
|
||||
Ui.Share.Bar.part("title").let { if (exists(it)) textsOf(it).firstOrNull() else null }
|
||||
|
||||
internal fun UiScope.awaitShareList(title: String) =
|
||||
poll(what = "the share list titled `$title` in place of the chat list") { shareListTitle() == title && !exists(Ui.ChatList.Search) }
|
||||
|
||||
internal fun UiScope.stopChatFromChatData(app: App) {
|
||||
chatData(app).management()
|
||||
click(Ui.Database.RunChat)
|
||||
poll(what = "the Stop chat? alert with Stop") { seesText("Stop chat?") && "Stop" in textsOf(Ui.Alert.Confirm(Ui.AlertSource.Shared)) }
|
||||
click(Ui.Alert.Confirm(Ui.AlertSource.Shared))
|
||||
poll(what = "the chat stopped on screen and in the core") { seesText("Chat is stopped") && chatModel.chatRunning.value == false && !chatRunningInCore() }
|
||||
}
|
||||
|
||||
internal class PostedNotification(val id: Int, val notification: Notification) {
|
||||
val title: String? get() = notification.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
|
||||
val text: String? get() = notification.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
|
||||
override fun toString() = "#$id `$title`: `$text`"
|
||||
}
|
||||
|
||||
private val notificationManager: NotificationManager get() = androidAppContext.getSystemService(NotificationManager::class.java)
|
||||
|
||||
internal fun messageNotifications(): List<PostedNotification> =
|
||||
notificationManager.activeNotifications
|
||||
.filter { it.notification.channelId == NtfManager.MessageChannel && (it.notification.flags and Notification.FLAG_GROUP_SUMMARY) == 0 }
|
||||
.map { PostedNotification(it.id, it.notification) }
|
||||
|
||||
internal fun messageSummaryShown(): Boolean =
|
||||
notificationManager.activeNotifications.any {
|
||||
it.notification.channelId == NtfManager.MessageChannel && it.notification.group == NtfManager.MessageGroup && (it.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0
|
||||
}
|
||||
|
||||
internal fun clearNotificationsViaApi() = notificationManager.cancelAll()
|
||||
|
||||
internal fun UiScope.awaitForeground() = poll(what = "the app in the foreground") { isAppOnForeground }
|
||||
|
||||
internal fun runningMainActivities(): List<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 UiScope.finishMainActivities() {
|
||||
val live = runningMainActivities()
|
||||
UiTestApp.onMain { live.forEach { it.finish() } }
|
||||
poll(what = "every MainActivity to be destroyed") { runningMainActivities().isEmpty() }
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import android.app.Notification
|
||||
import chat.simplex.app.model.NtfManager
|
||||
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
|
||||
import chat.simplex.app.ui.harness.ChatScreen
|
||||
import chat.simplex.app.ui.harness.UiTest
|
||||
import chat.simplex.app.ui.harness.awaitRowText
|
||||
import chat.simplex.app.ui.harness.backToChatList
|
||||
import chat.simplex.app.ui.harness.connect
|
||||
import chat.simplex.app.ui.harness.quiesce
|
||||
import chat.simplex.common.model.ChatInfo
|
||||
import chat.simplex.common.model.MsgFilter
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import org.junit.Test
|
||||
|
||||
class SystemNotificationsAndroidTest : UiTest() {
|
||||
@Test fun sysNtf_foreground_onlyForChatsNotOpen() = uiTest {
|
||||
val alice = app("alice"); val bob = cli("bob"); val cath = cli("cath")
|
||||
connect(bob)
|
||||
connect(cath)
|
||||
val bobId = alice.chatList.chatId("bob")
|
||||
alice.chatList.row("cath")
|
||||
quiesce()
|
||||
awaitForeground()
|
||||
clearNotificationsViaApi()
|
||||
val chat = alice.chatList.open("bob")
|
||||
bob sends "@alice $TO_OPEN_CHAT"
|
||||
chat.itemId(TO_OPEN_CHAT)
|
||||
cath sends "@alice $FROM_CATH"
|
||||
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "cath's message notification while bob's chat is open") { messageNotifications().any { it.title == "cath" } }
|
||||
val fromCath = messageNotifications().single { it.title == "cath" }
|
||||
check(fromCath.text == FROM_CATH && fromCath.notification.group == NtfManager.MessageGroup && (fromCath.notification.flags and Notification.FLAG_AUTO_CANCEL) != 0 && messageSummaryShown()) {
|
||||
"cath's notification: $fromCath, group ${fromCath.notification.group}, flags ${fromCath.notification.flags}, summary ${messageSummaryShown()}"
|
||||
}
|
||||
check(chatModel.chatId.value == bobId && messageNotifications().none { it.title == "bob" }) { "bob's message to his open chat was notified: ${messageNotifications()}" }
|
||||
|
||||
backToChatList()
|
||||
alice.chatList.rowMenu("cath", "ntfs.None", "Mute")
|
||||
poll(what = "cath muted and her notification cancelled") {
|
||||
alice.chatList.chat("cath").chatInfo.chatSettings?.enableNtfs == MsgFilter.None && messageNotifications().none { it.title == "cath" }
|
||||
}
|
||||
cath sends "@alice $MUTED"
|
||||
awaitRowText(alice, "cath", MUTED)
|
||||
bob sends "@alice $AFTER_CLOSE"
|
||||
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's notification now that his chat is closed") { messageNotifications().any { it.title == "bob" } }
|
||||
check(messageNotifications().map { it.title to it.text } == listOf("bob" to AFTER_CLOSE)) { "notifications after muting cath: ${messageNotifications()}" }
|
||||
|
||||
alice.chatList.open("bob")
|
||||
poll(what = "bob's notification cancelled once his chat is read") { messageNotifications().none { it.title == "bob" } }
|
||||
}
|
||||
|
||||
@Test fun sysNtf_tapOpensChat() = uiTest {
|
||||
val alice = app("alice"); val bob = cli("bob")
|
||||
connect(bob)
|
||||
val bobId = alice.chatList.chatId("bob")
|
||||
quiesce()
|
||||
awaitForeground()
|
||||
clearNotificationsViaApi()
|
||||
try {
|
||||
bob sends "@alice $HELLO"
|
||||
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's message notification") { messageNotifications().any { it.title == "bob" && it.text == HELLO } }
|
||||
check(chatModel.chatId.value == null && runningMainActivities().size == 1) { "before the tap: open chat ${chatModel.chatId.value}, activities ${runningMainActivities()}" }
|
||||
messageNotifications().single { it.title == "bob" }.notification.contentIntent.send()
|
||||
poll(what = "MainActivity recreated with bob's chat open") { runningMainActivities().size == 1 && chatModel.chatId.value == bobId }
|
||||
ChatScreen(this).itemId(HELLO)
|
||||
poll(what = "bob's notification cancelled once `$HELLO` is read") { messageNotifications().none { it.title == "bob" } }
|
||||
} finally {
|
||||
finishMainActivities()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun sysNtf_contactRequest_acceptAction() = uiTest {
|
||||
val alice = app("alice"); val bob = cli("bob")
|
||||
val address = alice.userPicker().createAddress()
|
||||
alice.chatList.row("Private notes")
|
||||
awaitForeground()
|
||||
clearNotificationsViaApi()
|
||||
bob sends "/c $address"
|
||||
bob expects "connection request sent!"
|
||||
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "bob's contact request notification") { messageNotifications().any { it.title == "bob" && it.text == REQUEST } }
|
||||
val request = messageNotifications().single { it.text == REQUEST }
|
||||
val actions = request.notification.actions.orEmpty()
|
||||
check(actions.map { it.title.toString() } == listOf("Accept") && request.notification.getLargeIcon() != null) {
|
||||
"request notification actions ${actions.map { it.title }}, large icon ${request.notification.getLargeIcon() != null}"
|
||||
}
|
||||
check(chatModel.chats.value.none { (it.chatInfo as? ChatInfo.Direct)?.contact?.let { c -> c.displayName == "bob" && c.ready } == true }) { "bob is a connected contact before Accept" }
|
||||
actions.single().actionIntent.send()
|
||||
bob.eventually("alice: contact is connected", CONNECT_TIMEOUT_MS)
|
||||
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "the request notification replaced by bob's Connected notification") {
|
||||
messageNotifications().let { ns -> ns.none { it.text == REQUEST } && ns.any { it.title == "bob" && it.text == "Connected" } }
|
||||
}
|
||||
check(chatModel.chatId.value == null) { "Accept in the notification opened a chat: ${chatModel.chatId.value}" }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TO_OPEN_CHAT = "sent to the open chat"
|
||||
const val FROM_CATH = "hello from cath"
|
||||
const val MUTED = "sent to a muted chat"
|
||||
const val AFTER_CLOSE = "sent after the chat closed"
|
||||
const val HELLO = "hello"
|
||||
const val REQUEST = "New contact request"
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package chat.simplex.app.ui.android
|
||||
|
||||
import chat.simplex.app.ui.harness.Bridge
|
||||
import chat.simplex.app.ui.harness.CONNECT_TIMEOUT_MS
|
||||
import chat.simplex.app.ui.harness.UiTest
|
||||
import chat.simplex.app.ui.harness.awaitNewRemoteHost
|
||||
import chat.simplex.app.ui.harness.awaitRemoteHostCode
|
||||
import chat.simplex.app.ui.harness.connect
|
||||
import chat.simplex.app.ui.harness.endDesktopSessionViaApi
|
||||
import chat.simplex.app.ui.harness.goBack
|
||||
import chat.simplex.app.ui.harness.linkToDesktop
|
||||
import chat.simplex.app.ui.harness.setDeviceName
|
||||
import chat.simplex.app.ui.harness.startRemoteHost
|
||||
import chat.simplex.app.ui.harness.useFromDesktop
|
||||
import chat.simplex.common.model.CIDirection
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.UIRemoteCtrlSessionState
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.ui.Ui
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class UseFromDesktopAndroidTest : UiTest() {
|
||||
@Test fun useFromDesktop_pasteVerifyConfirm_connected() = uiTest {
|
||||
val alice = app("alice")
|
||||
enableDeveloperToolsViaApi()
|
||||
val desktop = cli("desktop")
|
||||
desktop.setDeviceName(DESKTOP)
|
||||
val invitation = desktop.startRemoteHost(Bridge.remoteCtrlPort())
|
||||
setClipboardText(invitation)
|
||||
val screen = useFromDesktop(alice)
|
||||
val androidName = screen.deviceName()
|
||||
check(!seesText(invitation)) { "the desktop address is shown before pasting it" }
|
||||
screen.paste(invitation)
|
||||
screen.connect()
|
||||
val code = desktop.awaitRemoteHostCode()
|
||||
poll(timeoutMs = CONNECT_TIMEOUT_MS, what = "`Verify connection` with `$DESKTOP (new)` and the controller's session code `${code.take(23)}`") {
|
||||
seesText("Verify connection") && seesText("$DESKTOP (new)") && seesText(code.take(23)) && exists(Ui.Remote.Confirm)
|
||||
}
|
||||
check(chatModel.remoteCtrlSession.value?.sessionState !is UIRemoteCtrlSessionState.Connected) { "the session is connected before Confirm" }
|
||||
click(Ui.Remote.Confirm)
|
||||
assertEquals("the host name the controller shows", androidName, desktop.awaitNewRemoteHost())
|
||||
screen.awaitConnected(DESKTOP, code)
|
||||
endDesktopSessionViaApi(desktop)
|
||||
}
|
||||
|
||||
@Test fun useFromDesktop_controllerSendsAsAndroidUser() = uiTest {
|
||||
val alice = app("alice")
|
||||
enableDeveloperToolsViaApi()
|
||||
val bob = cli("bob")
|
||||
connect(bob)
|
||||
val desktop = cli("desktop")
|
||||
desktop.setDeviceName(DESKTOP)
|
||||
linkToDesktop(alice, desktop, DESKTOP, Bridge.remoteCtrlPort())
|
||||
desktop sends "/contacts"
|
||||
desktop.eventually("bob")
|
||||
desktop sends "@bob hi from desktop"
|
||||
bob.eventuallyMessage("alice> hi from desktop", CONNECT_TIMEOUT_MS)
|
||||
endDesktopSessionViaApi(desktop)
|
||||
goBack()
|
||||
poll(what = "`Connect to desktop` to close over alice's chat list") { !exists(Ui.Remote.DeviceName) && exists(Ui.ChatList.Search) }
|
||||
alice.chatList.open("bob")
|
||||
poll(what = "the message sent from the desktop as alice's sent item in the chat with bob") {
|
||||
seesText("hi from desktop", substring = true) && chatModel.chatsContext.chatItems.value.any { it.content.text == "hi from desktop" && it.chatDir is CIDirection.DirectSnd }
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableDeveloperToolsViaApi() = appPrefs.developerTools.set(true)
|
||||
|
||||
private companion object {
|
||||
const val DESKTOP = "My desktop"
|
||||
}
|
||||
}
|
||||
+46
-2
@@ -35,12 +35,56 @@ object Bridge {
|
||||
}
|
||||
|
||||
fun servers(): Pair<String, String> = call("servers").first().split(" ").let { it[1] to it[2] }
|
||||
|
||||
fun remoteCtrlPort(): Int = call("remotePort").first().removePrefix("ok ").toInt()
|
||||
}
|
||||
|
||||
object TestServers {
|
||||
fun start(name: String): String = Bridge.call("server start $name").first().removePrefix("ok ")
|
||||
|
||||
fun stop(name: String) { Bridge.call("server stop $name") }
|
||||
|
||||
fun reset(name: String): String = Bridge.call("server reset $name").first().removePrefix("ok ")
|
||||
|
||||
fun address(name: String): String = Bridge.call("server address $name").first().removePrefix("ok ")
|
||||
|
||||
fun restoreBaseline() { Bridge.call("server baseline") }
|
||||
}
|
||||
|
||||
object SocksProxy {
|
||||
private var started: Int? = null
|
||||
|
||||
val port: Int get() = started ?: error("the SOCKS proxy is not started; SocksProxy.start() starts it")
|
||||
|
||||
fun start() {
|
||||
if (started == null) started = Bridge.call("socks start").first().removePrefix("ok ").toInt()
|
||||
}
|
||||
|
||||
fun connections(): List<SocksConnection> =
|
||||
Bridge.call("socks connections").map { line ->
|
||||
val (host, port, username) = line.split(" ", limit = 3)
|
||||
SocksConnection(host, port.toInt(), username.takeIf { it != "-" })
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (started == null) return
|
||||
started = null
|
||||
Bridge.call("socks stop")
|
||||
}
|
||||
}
|
||||
|
||||
class CliPeer(name: String, private val flags: PeerFlags, waiter: Waiter) : PeerLines(name, waiter) {
|
||||
private var relayLink: String? = null
|
||||
|
||||
val relayAddress: String
|
||||
get() = relayLink ?: error("$name printed no chat relay address; start it with PeerFlags(relay = true) on a new database")
|
||||
|
||||
fun start() {
|
||||
val f = listOfNotNull("reactions".takeIf { flags.reactions }, "markRead".takeIf { flags.markRead }, "fullLinks".takeIf { flags.fullLinks })
|
||||
Bridge.call("start $name ${f.joinToString(",")}")
|
||||
val f = listOfNotNull(
|
||||
"reactions".takeIf { flags.reactions }, "markRead".takeIf { flags.markRead }, "fullLinks".takeIf { flags.fullLinks }, "relay".takeIf { flags.relay },
|
||||
"smp=${flags.smpServer}".takeIf { flags.smpServer != PeerFlags().smpServer }, "xftp=${flags.xftpServer}".takeIf { flags.xftpServer != PeerFlags().xftpServer }
|
||||
)
|
||||
Bridge.call("start $name ${f.joinToString(",")}").first().removePrefix("ok").trim().takeIf { it.isNotEmpty() }?.let { relayLink = it }
|
||||
}
|
||||
|
||||
fun stop() { runCatching { Bridge.call("stop $name") } }
|
||||
|
||||
+81
-10
@@ -23,6 +23,9 @@ import android.app.Instrumentation
|
||||
import android.content.Intent
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers
|
||||
import androidx.core.content.FileProvider
|
||||
import chat.simplex.common.helpers.APPLICATION_ID
|
||||
import java.io.File
|
||||
|
||||
abstract class UiTest {
|
||||
@get:Rule val rule: ComposeTestRule = createEmptyComposeRule()
|
||||
@@ -83,18 +86,36 @@ class UiScope(val rule: ComposeTestRule) {
|
||||
return App(this)
|
||||
}
|
||||
|
||||
fun restart(): App {
|
||||
UiTestApp.restartCore()
|
||||
UiTestApp.onMain { CommonPrefs.reload() }
|
||||
poll(timeoutMs = 60_000, what = "the core to start again with chat running") {
|
||||
fun restart(
|
||||
ready: () -> Boolean = {
|
||||
val m = chat.simplex.common.platform.chatModel
|
||||
m.chatDbStatus.value != null && !m.ctrlInitInProgress.value && m.chatRunning.value == true && m.currentUser.value != null && m.users.isNotEmpty()
|
||||
}
|
||||
},
|
||||
what: String = "the core to start again with chat running"
|
||||
): App {
|
||||
UiTestApp.restartCore()
|
||||
UiTestApp.onMain { CommonPrefs.reload() }
|
||||
poll(timeoutMs = 60_000, what = what) { ready() }
|
||||
Timing.phase("close") { close() }
|
||||
Timing.phase("launch") { launch() }
|
||||
return App(this)
|
||||
}
|
||||
|
||||
fun exchangeDir(): File = File(androidAppContext.filesDir, "uitest-exchange").apply { mkdirs() }
|
||||
|
||||
fun saveFileAs(file: File, action: () -> Unit) = answerIntent(listOf(Intent.ACTION_CREATE_DOCUMENT), file, action)
|
||||
|
||||
fun openFile(file: File, action: () -> Unit) = answerIntent(listOf(Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK), file, action)
|
||||
|
||||
private fun answerIntent(intentActions: List<String>, file: File, action: () -> Unit) {
|
||||
val uri = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", file)
|
||||
val result = Instrumentation.ActivityResult(Activity.RESULT_OK, Intent().setData(uri))
|
||||
intentActions.forEach { Intents.intending(IntentMatchers.hasAction(it)).respondWith(result) }
|
||||
val before = Intents.getIntents().count { it.action in intentActions }
|
||||
action()
|
||||
poll(what = "the app to ask for a file with ${intentActions.joinToString(" or ")}") { Intents.getIntents().count { it.action in intentActions } > before }
|
||||
}
|
||||
|
||||
fun keyboardShown(): Boolean {
|
||||
var shown = false
|
||||
UiTestApp.onMain {
|
||||
@@ -128,6 +149,7 @@ class UiScope(val rule: ComposeTestRule) {
|
||||
|
||||
fun finish() {
|
||||
peers.forEach { it.dispose() }
|
||||
runCatching { SocksProxy.stop() }
|
||||
Timing.phase("close") { close() }
|
||||
Timing.phase("drain") { UiTestApp.drain() }
|
||||
}
|
||||
@@ -155,6 +177,7 @@ class UiScope(val rule: ComposeTestRule) {
|
||||
val deadline = start + timeoutMs
|
||||
var busyAtDeadline: Set<Long>? = null
|
||||
var lastError: Throwable? = null
|
||||
var workEnded = false
|
||||
while (true) {
|
||||
rule.mainClock.advanceTimeBy(48, ignoreFrameDuration = true)
|
||||
rule.waitForIdle()
|
||||
@@ -165,10 +188,12 @@ class UiScope(val rule: ComposeTestRule) {
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val busy = if (now > deadline) busyAtDeadline ?: UiIdle.inFlight().map { it.id }.toSet().also { busyAtDeadline = it } else emptySet()
|
||||
if (now > deadline && (UiIdle.inFlight().none { it.id in busy } || now > deadline + EXTENSION_MS)) {
|
||||
val idle = now > deadline && UiIdle.inFlight().none { it.id in busy }
|
||||
if (now > deadline && ((idle && workEnded) || now > deadline + EXTENSION_MS)) {
|
||||
val shot = if (screenshot) FailureShots.take(what) else null
|
||||
throw AssertionError("timed out after $timeoutMs ms waiting for $what; ${lastError?.let { "last condition error: $it; " } ?: ""}in flight: ${UiIdle.inFlight().joinToString { it.what }}; screenshot: $shot; visible texts: ${texts().take(60)}")
|
||||
}
|
||||
workEnded = idle
|
||||
Thread.sleep(50)
|
||||
}
|
||||
}
|
||||
@@ -197,6 +222,31 @@ class UiScope(val rule: ComposeTestRule) {
|
||||
}
|
||||
}
|
||||
|
||||
fun scrollChatTowardOlder(pages: Int = 1): Boolean = scrollChat(pages, towardOlder = true)
|
||||
|
||||
fun scrollChatTowardNewer(pages: Int = 1): Boolean = scrollChat(pages, towardOlder = false)
|
||||
|
||||
private fun scrollChat(pages: Int, towardOlder: Boolean): Boolean {
|
||||
val list = rule.onNode(SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollToIndex) and SemanticsMatcher.keyIsDefined(SemanticsActions.ScrollBy) and hasAnyDescendant(chatItemNode))
|
||||
repeat(pages) {
|
||||
val items = rule.onAllNodes(chatItemNode).fetchSemanticsNodes()
|
||||
val tracked = (if (towardOlder) items.minByOrNull { it.boundsInRoot.top } else items.maxByOrNull { it.boundsInRoot.bottom })
|
||||
?: throw AssertionError("no chat items in view to scroll the chat")
|
||||
val tag = tracked.config[SemanticsProperties.TestTag]
|
||||
val before = tracked.boundsInRoot
|
||||
val node = list.fetchSemanticsNode()
|
||||
val dy = node.boundsInRoot.height * CHAT_SCROLL_PAGE
|
||||
rule.runOnUiThread { node.config[SemanticsActions.ScrollBy].action?.invoke(0f, if (towardOlder) dy else -dy) }
|
||||
settle()
|
||||
val after = rule.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().firstOrNull()?.boundsInRoot
|
||||
if (after == before) return false
|
||||
if (after == null || (if (towardOlder) after.top <= before.top else after.bottom >= before.bottom)) {
|
||||
throw AssertionError("scrolling the chat toward ${if (towardOlder) "older" else "newer"} messages moved $tag the other way: $before -> $after")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun waitStable(key: UiKey) {
|
||||
var last: androidx.compose.ui.geometry.Rect? = null
|
||||
try {
|
||||
@@ -246,13 +296,16 @@ class UiScope(val rule: ComposeTestRule) {
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun isMessageField(key: UiKey) = key.tag == Ui.Chat.Message.tag
|
||||
private fun isMessageField(key: UiKey) = key.id == Ui.Chat.Message.id
|
||||
|
||||
private fun messageEditText(key: UiKey) =
|
||||
Espresso.onView(org.hamcrest.CoreMatchers.allOf(ViewMatchers.isAssignableFrom(EditText::class.java), ViewMatchers.withTagValue(org.hamcrest.CoreMatchers.equalTo<Any>(key.tag))))
|
||||
|
||||
private fun setMessageField(key: UiKey, value: String) {
|
||||
waitFor(key)
|
||||
waitStable(key)
|
||||
settle()
|
||||
Espresso.onView(ViewMatchers.isAssignableFrom(EditText::class.java)).perform(CommitText(value))
|
||||
messageEditText(key).perform(CommitText(value))
|
||||
poll(what = "the message field to hold `$value`") { editableText(key) == value }
|
||||
}
|
||||
|
||||
@@ -285,7 +338,7 @@ class UiScope(val rule: ComposeTestRule) {
|
||||
}
|
||||
|
||||
fun pressEnter(key: UiKey) {
|
||||
if (isMessageField(key)) click(Ui.Chat.Send) else rule.onNode(matcher(key)).performImeAction()
|
||||
if (isMessageField(key)) click(key.part("send")) else rule.onNode(matcher(key)).performImeAction()
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
@@ -295,13 +348,21 @@ class UiScope(val rule: ComposeTestRule) {
|
||||
fun editableText(key: UiKey): String? {
|
||||
if (isMessageField(key)) {
|
||||
var t: String? = null
|
||||
Espresso.onView(ViewMatchers.isAssignableFrom(EditText::class.java)).check { v, _ -> t = (v as? EditText)?.text?.toString() }
|
||||
messageEditText(key).check { v, _ -> t = (v as? EditText)?.text?.toString() }
|
||||
return t
|
||||
}
|
||||
return rule.onAllNodes((matcher(key) or hasAnyAncestor(matcher(key))) and SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText), useUnmergedTree = true)
|
||||
.fetchSemanticsNodes().firstOrNull()?.config?.get(SemanticsProperties.EditableText)?.text
|
||||
}
|
||||
|
||||
fun hintOf(key: UiKey): String? {
|
||||
if (!isMessageField(key)) return if (editableText(key).isNullOrEmpty()) textsOf(key).firstOrNull() else null
|
||||
waitFor(key)
|
||||
var hint: String? = null
|
||||
messageEditText(key).check { v, _ -> hint = (v as? EditText)?.takeIf { it.text.isNullOrEmpty() }?.hint?.toString() }
|
||||
return hint
|
||||
}
|
||||
|
||||
fun textsOf(key: UiKey): List<String> {
|
||||
waitFor(key)
|
||||
val own = rule.onNode(matcher(key)).fetchSemanticsNode().config.getOrElseNullable(SemanticsProperties.Text) { null }.orEmpty().map { it.text }
|
||||
@@ -350,6 +411,13 @@ class UiScope(val rule: ComposeTestRule) {
|
||||
val inner = if (i.action == Intent.ACTION_CHOOSER) i.getParcelableExtra(Intent.EXTRA_INTENT) as? Intent else i
|
||||
inner?.getStringExtra(Intent.EXTRA_TEXT)
|
||||
} ?: (androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager).primaryClip?.getItemAt(0)?.text?.toString()
|
||||
|
||||
fun setClipboardText(text: String?) = UiTestApp.onMain {
|
||||
val cm = androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
if (text != null) cm.setPrimaryClip(android.content.ClipData.newPlainText("uitest", text))
|
||||
else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) cm.clearPrimaryClip()
|
||||
else error("clearing the clipboard needs Android 9")
|
||||
}
|
||||
}
|
||||
|
||||
object FailureShots {
|
||||
@@ -379,6 +447,9 @@ object Timing {
|
||||
}
|
||||
|
||||
private const val SETTLE_MS = 500L
|
||||
private const val CHAT_SCROLL_PAGE = 0.8f
|
||||
private val CHAT_ITEM_TAG = "${Ui.Chat.Item(0).id}["
|
||||
private val chatItemNode = SemanticsMatcher("a chat item") { it.config.getOrElseNullable(SemanticsProperties.TestTag) { null }?.startsWith(CHAT_ITEM_TAG) == true }
|
||||
private const val KEYBOARD_SHOW_MS = 1_500L
|
||||
private const val END_WORK_MS = 30_000L
|
||||
private const val EXTENSION_MS = 60_000L
|
||||
|
||||
+7
-1
@@ -75,6 +75,7 @@ object UiTestApp {
|
||||
Timing.phase("reset.stop running=${chatModel.chatRunning.value}") { stopCore() }
|
||||
Timing.phase("reset.delete") { deleteChatDatabaseFilesAndState() }
|
||||
Timing.phase("reset.prefs") { PrefStores.restore() }
|
||||
Timing.phase("reset.serversBaseline") { TestServers.restoreBaseline() }
|
||||
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand("cmd statusbar collapse").close()
|
||||
if (chat.simplex.app.SimplexService.isServiceStarted) Timing.phase("reset.ntfs") {
|
||||
onMain { chat.simplex.common.views.usersettings.changeNotificationsMode(chat.simplex.common.model.NotificationsMode.OFF, chatModel) }
|
||||
@@ -111,7 +112,12 @@ object UiTestApp {
|
||||
}
|
||||
|
||||
private fun stopCore() {
|
||||
waitUntil(10_000, "ctrlInitInProgress to clear") { !chatModel.ctrlInitInProgress.value }
|
||||
val deadline = System.currentTimeMillis() + 10_000
|
||||
while (chatModel.ctrlInitInProgress.value && System.currentTimeMillis() < deadline) Thread.sleep(50)
|
||||
if (chatModel.ctrlInitInProgress.value) {
|
||||
android.util.Log.w("UITEST", "ctrlInitInProgress still set after 10 s (left by a failed test); clearing it to reset")
|
||||
chatModel.ctrlInitInProgress.value = false
|
||||
}
|
||||
runBlocking {
|
||||
val ctrl = chatController.getChatCtrl()
|
||||
if (ctrl != null && ctrl != -1L && chatModel.chatRunning.value == true) chatController.apiStopChat()
|
||||
|
||||
Reference in new issue
Block a user