diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt index 94031139e52..8cfcdb67e66 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt @@ -42,6 +42,7 @@ import com.wire.android.tests.core.BaseUiTest import com.wire.android.tests.support.tags.Category import com.wire.android.tests.support.tags.TestCaseId import uiautomatorutils.UiWaitUtils.WaitUtils.waitFor +import uiautomatorutils.UiWaitUtils.waitUntilToastIsDisplayed @RunWith(AndroidJUnit4::class) class FileSharingBetweenTeams : BaseUiTest() { @@ -169,7 +170,7 @@ class FileSharingBetweenTeams : BaseUiTest() { step("Verify connection accepted toast and start a conversation with sender") { pages.connectedUserProfilePage.apply { - assertToastMessageIsDisplayed("Connection request accepted") + waitUntilToastIsDisplayed("Connection request accepted") clickStartConversationButton() } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt new file mode 100644 index 00000000000..94523324b20 --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt @@ -0,0 +1,470 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.tests.core.criticalFlows + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import backendUtils.BackendClient +import backendUtils.team.TeamHelper +import backendUtils.team.TeamRoles +import backendUtils.team.deleteTeam +import call.CallHelper +import call.CallingManager +import com.wire.android.tests.core.BaseUiTest +import com.wire.android.tests.core.pages.AllPages +import com.wire.android.tests.support.UiAutomatorSetup +import com.wire.android.tests.support.tags.Category +import com.wire.android.tests.support.tags.TestCaseId +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.test.inject +import service.TestServiceHelper +import uiautomatorutils.KeyboardUtils.closeKeyboardIfOpened +import uiautomatorutils.PermissionUtils.grantRuntimePermsForForegroundApp +import uiautomatorutils.UiWaitUtils.WaitUtils.waitFor +import uiautomatorutils.UiWaitUtils.assertToastDisplayed +import uiautomatorutils.UiWaitUtils.iSeeSystemMessage +import uiautomatorutils.UiWaitUtils.waitUntilToastIsDisplayed +import user.usermanager.ClientUserManager +import user.utils.ClientUser +import kotlin.getValue + +@RunWith(AndroidJUnit4::class) +class GroupVideoCall : BaseUiTest() { + private val pages: AllPages by inject() + private lateinit var device: UiDevice + private lateinit var context: Context + private lateinit var backendClient: BackendClient + private lateinit var teamHelper: TeamHelper + private lateinit var testServiceHelper: TestServiceHelper + private val callHelper by lazy { CallHelper() } + private var teamOwnerA: ClientUser? = null + private var teamOwnerB: ClientUser? = null + private lateinit var callingManager: CallingManager + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + device = UiAutomatorSetup.start(UiAutomatorSetup.APP_INTERNAL) + backendClient = BackendClient.loadBackend("STAGING") + teamHelper = TeamHelper() + testServiceHelper = TestServiceHelper(teamHelper.usersManager) + callHelper.init(teamHelper.usersManager) + callingManager = callHelper.callingManager + grantRuntimePermsForForegroundApp( + device, + android.Manifest.permission.RECORD_AUDIO, + android.Manifest.permission.CAMERA + ) + } + + @After + fun tearDown() { + runCatching { teamOwnerA?.deleteTeam(backendClient) } + runCatching { teamOwnerB?.deleteTeam(backendClient) } + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + @TestCaseId("TC-8608") + @Category("criticalFlow") + @Test + fun givenGroupCall_whenVideoIsEnabled_thenGroupVideoIsVisible() { + + step("Given backend teams are prepared (WeLikeCalls + IJoinCalls) with owners and members") { + teamHelper.usersManager.createTeamOwnerByAlias( + "user1Name", + "WeLikeCalls", + "en_US", + true, + backendClient, + context + ) + + teamHelper.userXAddsUsersToTeam( + "user1Name", + "user2Name, user3Name", + "WeLikeCalls", + TeamRoles.Member, + backendClient, + context, + true + ) + + teamHelper.usersManager.createTeamOwnerByAlias( + "user4Name", + "IJoinCalls", + "en_US", + true, + backendClient, + context + ) + } + + step("And WeLikeCalls team owner creates GroupVideoCall conversation with team members") { + testServiceHelper.userHasGroupConversationInTeam( + "user1Name", + "GroupVideoCall", + "user2Name, user3Name", + "WeLikeCalls" + ) + } + + step("And participant devices and unique username are prepared for group call") { + testServiceHelper.apply { + addDevice("user4Name", null, "Device2") + addDevice("user3Name", null, "Device1") + runBlocking { + usersSetUniqueUsername("user3Name") + } + } + } + + step("And team owners for WeLikeCalls and IJoinCalls are resolved") { + teamOwnerA = teamHelper.usersManager.findUserBy( + "user1Name", + ClientUserManager.FindBy.NAME_ALIAS + ) + teamOwnerB = teamHelper.usersManager.findUserBy( + "user4Name", + ClientUserManager.FindBy.NAME_ALIAS + ) + } + + step("And conference calling is enabled for WeLikeCalls and IJoinCalls via backdoor") { + runBlocking { + callHelper.enableConferenceCallingFeatureViaBackdoorTeam( + "user1Name", + "WeLikeCalls" + ) + callHelper.enableConferenceCallingFeatureViaBackdoorTeam( + "user4Name", + "IJoinCalls" + ) + } + } + + step("And I see welcome screen before login") { + pages.registrationPage.apply { + assertEmailWelcomePage() + } + } + + step("And I open staging deep link login flow") { + pages.loginPage.apply { + clickStagingDeepLink() + clickProceedButtonOnDeeplinkOverlay() + } + } + + step("And I login as WeLikeCalls team owner") { + pages.loginPage.apply { + enterTeamOwnerLoggingEmail(teamOwnerA?.email ?: "") + clickLoginButton() + enterTeamOwnerLoggingPassword(teamOwnerA?.password ?: "") + clickLoginButton() + } + } + + step("And I complete post-login permission and privacy prompts") { + pages.registrationPage.apply { + waitUntilLoginFlowIsCompleted() + clickAllowNotificationButton() + clickDeclineShareDataAlert() + } + } + + step("And I verify GroupVideoCall conversation is visible and start new conversation flow") { + pages.conversationListPage.apply { + assertGroupConversationVisible("GroupVideoCall") + tapStartNewConversationButton() + } + } + + step("And I open people search to find TeamOwnerB") { + pages.searchPage.apply { + tapSearchPeopleField() + } + } + + step("And I search TeamOwnerB by unique username") { + pages.searchPage.apply { + typeUniqueUserNameInSearchField(teamHelper, "user4Name") + } + } + + step("And I verify TeamOwnerB appears in search results and open profile") { + pages.searchPage.apply { + assertUsernameInSearchResultIs(teamOwnerB?.name ?: "") + tapUsernameInSearchResult(teamOwnerB?.name ?: "") + } + } + + step("And I verify unconnected profile belongs to TeamOwnerB") { + pages.unconnectedUserProfilePage.apply { + assertUserNameInUnconnectedUserProfilePage(teamOwnerB?.name ?: "") + } + } + + step("And I send connection request to TeamOwnerB and verify confirmation toast") { + pages.unconnectedUserProfilePage.apply { + clickConnectionRequestButton() + waitUntilToastIsDisplayed("Connection request sent") + } + } + + step("And I close unconnected profile and return to conversation list") { + pages.unconnectedUserProfilePage.apply { + clickCloseButtonOnUnconnectedUserProfilePage() + } + pages.conversationListPage.apply { + clickCloseButtonOnNewConversationScreen() + } + } + + step("And I verify pending status is visible for TeamOwnerB") { + pages.conversationListPage.apply { + assertConversationNameWithPendingStatusVisibleInConversationList( + teamOwnerB?.name ?: "" + ) + } + } + + step("And TeamOwnerB connection request is accepted via backend") { + runBlocking { + val user = teamHelper.usersManager.findUserByNameOrNameAlias("user4Name") + backendClient.acceptAllIncomingConnectionRequests(user) + } + } + + step("And I verify pending status is removed and GroupVideoCall conversation remains visible") { + pages.conversationListPage.apply { + assertPendingStatusIsNoLongerVisible() + assertGroupConversationVisible("GroupVideoCall") + } + } + + step("And I verify TeamOwnerB conversation is visible and open GroupVideoCall") { + pages.conversationListPage.apply { + assertConversationIsVisibleWithTeamOwner(teamOwnerB?.name ?: "") + tapConversationNameInConversationList("GroupVideoCall") + } + } + + step("And I open GroupVideoCall conversation details") { + pages.conversationViewPage.apply { + clickOnGroupConversationDetails("GroupVideoCall") + } + } + + step("And I open participants tab and start add participant flow") { + pages.groupConversationDetailsPage.apply { + tapOnParticipantsTab() + tapAddParticipantsButton() + } + } + + step("And I select TeamOwnerB from participant suggestions") { + pages.groupConversationDetailsPage.apply { + assertUsernameInSuggestionsListIs(teamOwnerB?.name ?: "") + selectUserInSuggestionList(teamOwnerB?.name ?: "") + tapContinueButton() + } + } + + step("And I verify TeamOwnerB is added to participants list") { + pages.groupConversationDetailsPage.apply { + assertUsernameIsAddedToParticipantsList(teamOwnerB?.name ?: "") + tapCloseButtonOnGroupConversationDetailsPage() + } + } + + step("And I verify system message confirms TeamOwnerB was added") { + iSeeSystemMessage("You added ${teamOwnerB?.name ?: ""} to the conversation") + } + + step("And , , and start instances using Chrome") { + runBlocking { + callHelper.userXStartsInstance( + "user2Name, user3Name, user4Name", + "Chrome" + ) + } + } + + step("And , , and auto-accept the next incoming call") { + runBlocking { + callHelper.userXAcceptsNextIncomingCallAutomatically( + "user2Name, user3Name, user4Name" + ) + } + } + + step("When I start group call from GroupVideoCall conversation") { + pages.conversationViewPage.apply { + iTapStartCallButton() + } + } + + step("Then , , and verify waiting instance status changes to active within 90 seconds") { + runBlocking { + callHelper.userVerifiesCallStatusToUserY( + "user2Name, user3Name, user4Name", + "active", + 90 + ) + } + } + + step("And I see ongoing group call") { + pages.callingPage.apply { + iSeeOngoingGroupCall() + } + } + + step("And I see users , , and in ongoing group call") { + callHelper.iSeeParticipantsInGroupCall("user2Name, user3Name, user4Name") + } + + step("And I turn camera on") { + pages.callingPage.apply { + iTurnCameraOn() + } + } + + step("And users , , and switch video on") { + runBlocking { + val callParticipantsSwitchVideoOn = + teamHelper.usersManager.splitAliases("user2Name, user3Name, user4Name") + callingManager.switchVideoOn(callParticipantsSwitchVideoOn) + } + } + + step("And users , , and verify audio and video are received") { + runBlocking { + val assertCallParticipantsReceiveAudioVideo = + teamHelper.usersManager.splitAliases("user2Name, user3Name, user4Name") + callingManager.verifyReceiveAudioAndVideo(assertCallParticipantsReceiveAudioVideo) + } + } + + step("And I see users , , and in ongoing group video call") { + callHelper.iSeeParticipantsInGroupVideoCall("user2Name, user3Name, user4Name") + } + + step("And I minimise ongoing call to continue conversation actions") { + pages.callingPage.apply { + iMinimiseOngoingCall() + } + } + + step("And I tap ping button in conversation view") { + pages.conversationViewPage.apply { + tapMessageInInputField() + tapPingButton() + } + } + + step("And I see confirmation alert with text \"Are you sure you want to ping 4 people?\" in conversation view") { + pages.conversationViewPage.apply { + iSeePingModalWithText("Are you sure you want to ping 4 people?") + } + } + + step("And I confirm ping and see system message 'You pinged'") { + pages.conversationViewPage.apply { + tapPingButtonModal() + iSeeSystemMessage("You pinged") + closeKeyboardIfOpened() + } + } + + step("And I attempt to start audio recording during ongoing call") { + pages.conversationViewPage.apply { + // `assertToastDisplayed` starts an accessibility-event listener before running `trigger`. + // We must perform the tap/share actions inside `trigger`; otherwise the transient toast can appear and disappear before observation starts. + assertToastDisplayed("You can't record an audio message during a call.", trigger = { + iTapFileSharingButton() + tapSharingOption("Audio") + iTapFileSharingButton() + }) + } + } + + step("And sends audio file message via device Device1 to GroupVideoCall conversation") { + pages.conversationViewPage.apply { + testServiceHelper.contactSendsLocalAudioConversation( + context, + "AudioFile", + "user3Name", + "Device1", + "GroupVideoCall" + ) + } + } + + step("And I see audio file message in conversation") { + pages.conversationViewPage.apply { + assertAudioMessageIsVisible() + } + } + + step("And I see audio playback time starts at zero") { + pages.conversationViewPage.apply { + assertAudioTimeStartsAtZero() + } + } + waitFor(1) + step("And I play audio message") { + pages.conversationViewPage.apply { + clickPlayButtonOnAudioMessage() + } + } + + step("And I pause audio message after 10 seconds") { + pages.conversationViewPage.apply { + waitFor(10) // wait to allow an audio file to play + clickPauseButtonOnAudioMessage() + } + } + + step("Then I verify audio playback time is no longer zero") { + pages.conversationViewPage.apply { + assertAudioTimeIsNotZeroAnymore() + } + } + + step("And I restore ongoing group call and verify users , , and remain connected") { + pages.callingPage.apply { + iRestoreOngoingCall() + } + callHelper.iSeeParticipantsInGroupCall("user2Name, user3Name, user4Name") + } + + step("And I hang up group call and verify call is ended") { + pages.callingPage.apply { + iTapOnHangUpButton() + iDoNotSeeOngoingGroupCall() + } + } + } +} diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt index 25b14d6e463..f7e586cc4f9 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt @@ -66,8 +66,8 @@ class PersonalAccountLifeCycle : BaseUiTest() { @After fun tearDown() { - teamOwner?.deleteTeam(backendClient) - personalUser?.deleteUser(backendClient) + teamOwner?.deleteTeam(backendClient) + personalUser?.deleteUser(backendClient) } @Suppress("CyclomaticComplexMethod", "LongMethod") @@ -181,7 +181,14 @@ class PersonalAccountLifeCycle : BaseUiTest() { tapConversationNameInConversationList(teamOwner?.name ?: "") } } - + // Wait for the personal 1:1 conversation to fully settle in MLS before sending. + // The 5s settle window specifically reduces intermittent test-service send flakes right after MLS transition. + step("Wait until personal 1:1 conversation is upgraded to MLS") { + pages.conversationViewPage.waitUntilConversationTurnsMls( + timeoutMs = 20_000, + settleAfterDetectedMs = 5_000 + ) + } step("Send message to team owner in 1:1 conversation") { pages.conversationViewPage.apply { typeMessageInInputField("Hello Team Owner") @@ -191,7 +198,7 @@ class PersonalAccountLifeCycle : BaseUiTest() { } step("Receive message from team owner via backend in 1:1 conversation") { - testServiceHelper.userSendMessageToConversationObj( + testServiceHelper.userSendMessageToPersonalMlsConversation( "user1Name", "Hello to you too!", "Device1", @@ -199,6 +206,7 @@ class PersonalAccountLifeCycle : BaseUiTest() { false ) + closeKeyboardIfOpened() pages.conversationViewPage.apply { assertReceivedMessageIsVisibleInCurrentConversation("Hello to you too!") } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CallingPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CallingPage.kt index 8de3fba3762..cabcdf213e3 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CallingPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CallingPage.kt @@ -28,8 +28,9 @@ data class CallingPage(private val device: UiDevice) { private val restoreCallButton = UiSelectorParams(text = "RETURN TO CALL") - fun iSeeOngoingGroupCall(): CallingPage { + private val turnCameraOnButton = UiSelectorParams(description = "Turn camera on") + fun iSeeOngoingGroupCall(): CallingPage { try { UiWaitUtils.waitElement(hangUpCallButton) } catch (e: AssertionError) { @@ -47,4 +48,23 @@ data class CallingPage(private val device: UiDevice) { UiWaitUtils.waitElement(restoreCallButton).click() return this } + + fun iTurnCameraOn(): CallingPage { + UiWaitUtils.waitElement(turnCameraOnButton).click() + return this + } + + fun iTapOnHangUpButton(): CallingPage { + UiWaitUtils.waitElement(hangUpCallButton).click() + return this + } + + fun iDoNotSeeOngoingGroupCall(): CallingPage { + try { + UiWaitUtils.waitElement(hangUpCallButton, timeoutMillis = 15_000) + } catch (e: AssertionError) { + return this + } + throw AssertionError("Ongoing call still displayed") + } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt index c32e4cc8a79..d872f913672 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt @@ -17,9 +17,12 @@ */ package com.wire.android.tests.core.pages +import android.os.SystemClock import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By +import androidx.test.uiautomator.StaleObjectException import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector import org.junit.Assert import uiautomatorutils.UiSelectorParams import uiautomatorutils.UiWaitUtils @@ -45,17 +48,9 @@ data class ConversationListPage(private val device: UiDevice) { UiSelectorParams(text = conversationName) } private val startNewConversation = UiSelectorParams(description = "New. Start a new conversation") - private val backArrowButtonInsideSearchField = UiSelectorParams( - className = "android.view.View", - description = "Go back to add participants view" - ) - - private val closeNewConversationButton = UiSelectorParams( - description = "Close new conversation view" - ) - - private val userConversationNamePendingLabelString = UiSelectorParams(description = "pending approval of connection request") + private val userConversationNamePendingLabelSelector = + UiSelector().description("pending approval of connection request") fun assertConversationListVisible(): ConversationListPage { val heading = UiWaitUtils.waitElement(conversationListHeading) Assert.assertTrue( @@ -70,9 +65,39 @@ data class ConversationListPage(private val device: UiDevice) { return this } - fun clickSettingsButtonOnMenuEntry(): ConversationListPage { - UiWaitUtils.waitElement(settingsButton).click() - return this + fun clickSettingsButtonOnMenuEntry(timeoutMs: Long = 10_000): ConversationListPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + var lastMenuClickAt = 0L + + while (SystemClock.uptimeMillis() < deadline) { + if (tryClickIfVisible(settingsButton)) { + return this + } + + val now = SystemClock.uptimeMillis() + if (now - lastMenuClickAt >= 600 && tryClickIfVisible(mainMenuButton)) { + lastMenuClickAt = now + device.waitForIdle(300) + } + + SystemClock.sleep(120) + } + + throw AssertionError("Settings menu entry was not found within ${timeoutMs}ms.") + } + + private fun tryClickIfVisible(selector: UiSelectorParams): Boolean { + val element = UiWaitUtils.findElementOrNull(selector) ?: return false + return try { + if (!element.visibleBounds.isEmpty && element.isEnabled) { + element.click() + true + } else { + false + } + } catch (_: StaleObjectException) { + false + } } fun clickConversationsButtonOnMenuEntry(): ConversationListPage { @@ -153,14 +178,21 @@ data class ConversationListPage(private val device: UiDevice) { return this } - fun tapBackArrowButtonInsideSearchField(): ConversationListPage { - val button = UiWaitUtils.waitElement(backArrowButtonInsideSearchField) - button.click() - return this - } + fun clickCloseButtonOnNewConversationScreen(timeoutMs: Long = 5_000): ConversationListPage { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + val close = device.findObject( + UiSelector() + .className("android.view.View") + .description("Close new conversation view") + ) + + if (!close.waitForExists(timeoutMs)) { + throw AssertionError("Close button not found within ${timeoutMs}ms") + } + + close.click() - fun clickCloseButtonOnNewConversationScreen(): ConversationListPage { - UiWaitUtils.waitElement(closeNewConversationButton).click() return this } @@ -170,29 +202,43 @@ data class ConversationListPage(private val device: UiDevice) { return this } + @Suppress("ThrowsCount") fun assertConversationNameWithPendingStatusVisibleInConversationList(userName: String): ConversationListPage { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + // 1) Assert user name is visible try { - UiWaitUtils.waitElement(UiSelectorParams(text = userName)) - } catch (e: AssertionError) { + val userObj = device.findObject(UiSelector().text(userName)) + if (!userObj.waitForExists(10_000)) { + throw AssertionError("User '$userName' is not visible in the conversation list") + } + } catch (e: Throwable) { throw AssertionError("User '$userName' is not visible in the conversation list", e) } - // Assert the 'pending' badge is visible + + // 2) Assert the 'pending' badge is visible try { - UiWaitUtils.waitElement(userConversationNamePendingLabelString) - } catch (e: AssertionError) { + val pendingObj = device.findObject(userConversationNamePendingLabelSelector) + if (!pendingObj.waitForExists(10_000)) { + throw AssertionError("Pending status is not visible for user '$userName'") + } + } catch (e: Throwable) { throw AssertionError("Pending status is not visible for user '$userName'", e) } + return this } fun assertPendingStatusIsNoLongerVisible(): ConversationListPage { - val pending = runCatching { - UiWaitUtils.waitElement(userConversationNamePendingLabelString) - }.getOrNull() + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + UiWaitUtils.waitUntilElementGone( + device = device, + selector = userConversationNamePendingLabelSelector, + timeoutMillis = 10_000, + pollingInterval = 250 + ) - if (pending != null && !pending.visibleBounds.isEmpty) { - throw AssertionError("Pending status is still visible (expected it to be gone)") - } return this } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt index ebbbc7ea35e..361f0fbe5d0 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt @@ -17,6 +17,7 @@ */ package com.wire.android.tests.core.pages +import android.os.SystemClock import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice @@ -55,6 +56,7 @@ data class ConversationViewPage(private val device: UiDevice) { private val saveButton = UiSelectorParams(text = "Save") private val openButton = UiSelectorParams(text = "Open") + private val cancelButton = UiSelectorParams(text = "Cancel") private val downloadButtonOnVideoFile = UiSelectorParams(text = "Tap to download") private val videoDurationLocator = UiSelectorParams(text = "00:03") @@ -63,6 +65,7 @@ data class ConversationViewPage(private val device: UiDevice) { private fun conversationDetails1On1(userName: String) = UiSelector().className("android.widget.TextView").text(userName) + private fun conversationDetailsGroup(userName: String) = UiSelectorParams(text = userName) private val sendButton = UiSelectorParams(description = "Send") private val backButton = UiSelectorParams(description = "Go back to conversation list") @@ -71,6 +74,16 @@ data class ConversationViewPage(private val device: UiDevice) { private val selfDeletingMessageLabel = UiSelectorParams(description = " Self-deleting message") + private val pingButton = UiSelectorParams(description = "Ping") + + private val pingButtonOnModal = UiSelectorParams(text = "Ping") + + private val mlsUpgradeMessageSelectors = listOf( + UiSelectorParams(textContains = "This conversation now uses the new Messaging"), + UiSelectorParams(textContains = "Layer Security (MLS) protocol"), + UiSelectorParams(textContains = "latest version of Wire on your devices") + ) + private fun selfDeleteOption(label: String): UiSelectorParams { return UiSelectorParams(text = label, className = "android.widget.TextView") } @@ -78,6 +91,7 @@ data class ConversationViewPage(private val device: UiDevice) { private fun sharingOption(label: String): UiSelectorParams { return UiSelectorParams(text = label, className = "android.widget.TextView") } + private fun fileWithName(name: String): UiSelectorParams { return UiSelectorParams(text = name) } @@ -174,10 +188,24 @@ data class ConversationViewPage(private val device: UiDevice) { return this } - fun assertFileActionModalIsVisible(): ConversationViewPage { - val modalText = UiWaitUtils.waitElement(modalTextLocator) - assertTrue("The file action modal is not visible.", !modalText.visibleBounds.isEmpty) - return this + fun assertFileActionModalIsVisible(timeoutMs: Long = 8_000): ConversationViewPage { + val modalAnchors = listOf(modalTextLocator, saveButtonLocator, openButton, cancelButton) + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val isVisible = modalAnchors + .asSequence() + .mapNotNull(UiWaitUtils::findElementOrNull) + .any { runCatching { !it.visibleBounds.isEmpty }.getOrDefault(false) } + + if (isVisible) { + return this + } + + SystemClock.sleep(150) + } + + throw AssertionError("The file action modal was not visible within ${timeoutMs}ms.") } fun tapSaveButtonOnModal(): ConversationViewPage { @@ -225,8 +253,20 @@ data class ConversationViewPage(private val device: UiDevice) { return this } - fun clickSaveButtonOnDownloadModal(): ConversationViewPage { - UiWaitUtils.waitElement(saveButton).click() + fun clickSaveButtonOnDownloadModal(timeoutMs: Long = 8_000): ConversationViewPage { + val save = UiWaitUtils.waitElement(saveButton, timeoutMillis = timeoutMs) + val bounds = runCatching { save.visibleBounds }.getOrNull() + + runCatching { save.click() } + device.waitForIdle(300) + + val stillVisible = UiWaitUtils.findElementOrNull(saveButton) + ?.let { runCatching { !it.visibleBounds.isEmpty }.getOrDefault(false) } == true + + if (stillVisible && bounds != null && !bounds.isEmpty) { + device.click(bounds.centerX(), bounds.centerY()) + } + return this } @@ -268,7 +308,7 @@ data class ConversationViewPage(private val device: UiDevice) { // Perform fling (fast scroll) to the bottom val success = scrollable.flingToEnd(10) - println("✅ Scrolled to bottom: $success") + println(" Scrolled to bottom: $success") } catch (e: Exception) { println("Failed to scroll: ${e.message}") } @@ -412,6 +452,32 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + fun waitUntilConversationTurnsMls( + timeoutMs: Long = 20_000, + settleAfterDetectedMs: Long = 0 + ): ConversationViewPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val mlsMarker = mlsUpgradeMessageSelectors + .asSequence() + .mapNotNull(UiWaitUtils::findElementOrNull) + .firstOrNull { !it.visibleBounds.isEmpty } + + if (mlsMarker != null) { + // MLS banner can appear slightly before the conversation is fully ready for a first outbound message. + if (settleAfterDetectedMs > 0) { + SystemClock.sleep(settleAfterDetectedMs) + } + return this + } + + SystemClock.sleep(200) + } + + throw AssertionError("MLS upgrade system message was not visible within ${timeoutMs}ms.") + } + fun click1On1ConversationDetails(userName: String): ConversationViewPage { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) val userName = device.findObject(conversationDetails1On1(userName)) @@ -421,6 +487,19 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + fun clickOnGroupConversationDetails(userName: String): ConversationViewPage { + val params = conversationDetailsGroup(userName) + + UiWaitUtils.waitUntilVisible( + params = params, + timeoutMs = 5_000, + errorMessage = "Group conversation details for user '$userName' not visible" + ) + + UiWaitUtils.waitElement(params).click() + return this + } + fun iTapStartCallButton(): ConversationViewPage { UiWaitUtils.waitElement(startCallButton).click() return this @@ -470,4 +549,26 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + + fun tapPingButton(): ConversationViewPage { + UiWaitUtils.waitElement(pingButton).click() + return this + } + + fun tapPingButtonModal(): ConversationViewPage { + UiWaitUtils.waitElement(pingButtonOnModal).click() + return this + } + + fun iSeePingModalWithText(message: String): ConversationViewPage { + val messageSelector = UiSelectorParams(text = message) + + try { + UiWaitUtils.waitElement(messageSelector) + } catch (e: AssertionError) { + throw AssertionError("Message '$message' is not not visible on ping modal.", e) + } + + return this + } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/DocumentsUIPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/DocumentsUIPage.kt index b8f0061e46e..7ead7afeb08 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/DocumentsUIPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/DocumentsUIPage.kt @@ -18,15 +18,26 @@ package com.wire.android.tests.core.pages import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.StaleObjectException import uiautomatorutils.UiSelectorParams import uiautomatorutils.UiWaitUtils data class DocumentsUIPage(private val device: UiDevice) { private val sendButton = UiSelectorParams(text = "Send") + private val downloadsOption = UiSelectorParams(textContains = "Download") + private val showRootsButton = UiSelectorParams(description = "Show roots") fun iSeeQrCodeImage(fileName: String = "my-test-qr.png"): DocumentsUIPage { val qrCodeImage = UiSelectorParams(text = fileName) + // Picker may open in Recent folder; switch to Downloads so the generated QR file is visible. + if (UiWaitUtils.findElementOrNull(qrCodeImage) == null) { + if (!clickWithRetry(downloadsOption)) { + clickWithRetry(showRootsButton) + clickWithRetry(downloadsOption) + } + } + try { UiWaitUtils.waitElement(qrCodeImage) } catch (e: AssertionError) { @@ -35,6 +46,20 @@ data class DocumentsUIPage(private val device: UiDevice) { return this } + private fun clickWithRetry(selector: UiSelectorParams, attempts: Int = 3): Boolean { + repeat(attempts) { + try { + UiWaitUtils.waitElement(selector, timeoutMillis = 1500).click() + return true + } catch (_: StaleObjectException) { + // Retry with a fresh node. + } catch (_: AssertionError) { + // Selector not present in current picker pane. + } + } + return false + } + fun iOpenDisplayedQrCodeImage(fileName: String = "my-test-qr.png"): DocumentsUIPage { val qrCodeImage = UiSelectorParams(text = fileName) UiWaitUtils.waitElement(qrCodeImage).click() diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt index d705e178c85..86db85d3cf2 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/GroupConversationDetailsPage.kt @@ -29,6 +29,14 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { private val removeGroupButton = UiSelectorParams(text = "Remove") + private val participantsTab = UiSelectorParams(text = "PARTICIPANTS") + + private val addParticipantsButton = UiSelectorParams(text = "Add participants") + + private val continueButton = UiSelectorParams(text = "Continue") + + private val closeButtonOnGroupConversationDetailsPage = UiSelectorParams(description = "Close conversation details") + fun tapShowMoreOptionsButton() { UiWaitUtils.waitElement(showMoreOptionsButton).click() } @@ -40,4 +48,73 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { fun tapRemoveGroupButton() { UiWaitUtils.waitElement(removeGroupButton).click() } + + fun tapOnParticipantsTab() { + UiWaitUtils.waitElement(participantsTab).click() + } + + fun tapAddParticipantsButton() { + UiWaitUtils.waitElement(addParticipantsButton).click() + } + + fun assertUsernameInSuggestionsListIs(expectedHandle: String): GroupConversationDetailsPage { + val handleSelector = UiSelectorParams( + className = "android.widget.TextView", + text = expectedHandle + ) + try { + UiWaitUtils.waitElement(params = handleSelector) + } catch (e: AssertionError) { + throw AssertionError( + "Expected user name in suggestion results to be '$expectedHandle' but its not '$expectedHandle'", + e + ) + } + return this + } + + fun selectUserInSuggestionList(expectedHandle: String): GroupConversationDetailsPage { + val handleSelector = UiSelectorParams( + className = "android.widget.TextView", + text = expectedHandle + ) + + val handleTextView = try { + UiWaitUtils.waitElement(params = handleSelector) + } catch (e: AssertionError) { + throw AssertionError( + "Expected user name '$expectedHandle' was not found in suggestion list", + e + ) + } + + handleTextView.parent.click() + + return this + } + + fun tapContinueButton() { + UiWaitUtils.waitElement(continueButton).click() + } + + fun assertUsernameIsAddedToParticipantsList(expectedHandle: String): GroupConversationDetailsPage { + val handleSelector = UiSelectorParams( + className = "android.widget.TextView", + text = expectedHandle + ) + try { + UiWaitUtils.waitElement(params = handleSelector) + } catch (e: AssertionError) { + throw AssertionError( + "Expected user name in participants list results to be '$expectedHandle' but its not '$expectedHandle'", + e + ) + } + return this + } + + fun tapCloseButtonOnGroupConversationDetailsPage(): GroupConversationDetailsPage { + UiWaitUtils.waitElement(closeButtonOnGroupConversationDetailsPage).click() + return this + } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt index 75c43aa77af..26b7fe58a31 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt @@ -18,8 +18,10 @@ package com.wire.android.tests.core.pages import androidx.test.espresso.matcher.ViewMatchers.assertThat +import android.os.SystemClock import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By +import androidx.test.uiautomator.StaleObjectException import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import org.hamcrest.CoreMatchers.`is` @@ -50,10 +52,11 @@ class RegistrationPage(private val device: UiDevice) { private val userNameHelpText = UiSelectorParams(textContains = "At least 2 character") private val editTextClass = By.clazz("android.widget.EditText") private val confirmButton = UiSelectorParams(text = "Confirm") - private val allowNotificationButton = - UiSelectorParams( - resourceId = "com.android.permissioncontroller:id/permission_allow_button" - ) + private val allowNotificationButtons = listOf( + UiSelectorParams(resourceId = "com.android.permissioncontroller:id/permission_allow_button"), + UiSelectorParams(text = "Allow") + ) + private val consentDialogTitle = UiSelectorParams(textContains = "Consent to share user data") private val declineButton = UiSelectorParams(text = "Decline") private val loginButtonGoneSelector = UiSelector().resourceId("loginButton") private val settingUpWireGoneSelector = UiSelector() @@ -69,15 +72,52 @@ class RegistrationPage(private val device: UiDevice) { } fun enterPersonalUserRegistrationEmail(email: String): RegistrationPage { - val emailIputfield = UiWaitUtils.waitElement(emailInputField) - emailIputfield.click() - emailIputfield.text = email - return this + repeat(3) { + try { + UiWaitUtils.waitElement(emailInputField, timeoutMillis = 2_000).click() + UiWaitUtils.waitElement(emailInputField, timeoutMillis = 2_000).text = email + return this + } catch (_: StaleObjectException) { + SystemClock.sleep(150) + } catch (_: AssertionError) { + SystemClock.sleep(150) + } + } + + throw AssertionError("Could not enter registration email: email input field was unstable.") } - fun clickLoginButton(): RegistrationPage { - UiWaitUtils.waitElement(loginButton).click() - return this + @Suppress("NestedBlockDepth") + fun clickLoginButton(timeoutMs: Long = 10_000): RegistrationPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + var lastError: AssertionError? = null + + while (SystemClock.uptimeMillis() < deadline) { + try { + UiWaitUtils.waitElement(loginButton, timeoutMillis = 1_500).click() + return this + } catch (e: AssertionError) { + lastError = e + try { + val button = UiWaitUtils.findElementOrNull(loginButton) + if (button != null && !button.visibleBounds.isEmpty && button.isEnabled) { + button.click() + return this + } + } catch (_: StaleObjectException) { + // Retry with a freshly resolved node. + } + } catch (_: StaleObjectException) { + // Retry with a freshly resolved node. + } + + SystemClock.sleep(200) + } + + throw AssertionError( + "Login button was not clickable within ${timeoutMs}ms.", + lastError + ) } fun clickCreateAccountButton(): RegistrationPage { @@ -158,13 +198,12 @@ class RegistrationPage(private val device: UiDevice) { val codeInputField = UiWaitUtils.waitElement(UiSelectorParams(className = "android.widget.EditText")) codeInputField.click() codeInputField.text = code + UiWaitUtils.waitElement(userNameInfoText, timeoutMillis = 15_000) return this } fun assertEnterYourUserNameInfoText(): RegistrationPage { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - waitUntilElementGone(device, UiSelector().text("Resend code"), timeoutMillis = 10_000) - val info = UiWaitUtils.waitElement(userNameInfoText) + val info = UiWaitUtils.waitElement(userNameInfoText, timeoutMillis = 15_000) assertTrue("Username info not visible", !info.visibleBounds.isEmpty) return this } @@ -187,14 +226,53 @@ class RegistrationPage(private val device: UiDevice) { return this } - fun clickAllowNotificationButton(): RegistrationPage { - UiWaitUtils.waitElement(allowNotificationButton).click() + fun clickAllowNotificationButton(timeoutMs: Long = 15_000): RegistrationPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val button = allowNotificationButtons + .asSequence() + .mapNotNull(UiWaitUtils::findElementOrNull) + .firstOrNull { !it.visibleBounds.isEmpty && it.isEnabled } + + if (button != null) { + button.click() + return this + } + + SystemClock.sleep(200) + } + + // On some devices/runs the permission is already granted and this dialog never appears. return this } - fun clickDeclineShareDataAlert(): RegistrationPage { - UiWaitUtils.waitElement(declineButton).click() - return this + @Suppress("MagicNumber") + fun clickDeclineShareDataAlert(timeoutMs: Long = 10_000): RegistrationPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val decline = UiWaitUtils.findElementOrNull(declineButton) + if (decline != null && !decline.visibleBounds.isEmpty && decline.isEnabled) { + val bounds = decline.visibleBounds + runCatching { decline.click() } + val stillVisibleAfterClick = UiWaitUtils.findElementOrNull(declineButton)?.let { !it.visibleBounds.isEmpty } == true + if (stillVisibleAfterClick && !bounds.isEmpty) { + device.click(bounds.centerX(), bounds.centerY()) + } + device.waitForIdle(300) + } + + val dialogVisible = UiWaitUtils.findElementOrNull(consentDialogTitle)?.let { !it.visibleBounds.isEmpty } == true + val declineVisible = UiWaitUtils.findElementOrNull(declineButton)?.let { !it.visibleBounds.isEmpty } == true + if (!dialogVisible && !declineVisible) { + return this + } + + SystemClock.sleep(150) + } + + throw AssertionError("Share data consent alert was not dismissed within ${timeoutMs}ms.") } fun clickAgreeShareDataAlert(): RegistrationPage { @@ -224,7 +302,7 @@ class RegistrationPage(private val device: UiDevice) { fun waitUntilRegistrationFlowIsCompleted(): RegistrationPage { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - waitUntilElementGone(device, UiSelector().text("Confirm"), timeoutMillis = 14_000) + waitUntilElementGone(device, UiSelector().text("Confirm"), timeoutMillis = 16_000) return this } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt index 1f8e6799495..fe928478f43 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt @@ -19,6 +19,7 @@ package com.wire.android.tests.core.pages import android.content.Intent import android.net.Uri +import android.os.SystemClock import androidx.test.espresso.matcher.ViewMatchers.assertThat import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By @@ -426,15 +427,19 @@ data class SettingsPage(private val device: UiDevice) { return this } - fun assertDeleteAccountConfirmationModalIsNoLongerVisible(): SettingsPage { - val modal = runCatching { - UiWaitUtils.waitElement(deleteAccountConfirmationModal) - }.getOrNull() + fun assertDeleteAccountConfirmationModalIsNoLongerVisible(timeoutMs: Long = 10_000): SettingsPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs - if (modal != null && !modal.visibleBounds.isEmpty) { - throw AssertionError("Delete account confirmation modal is still visible (expected it to be gone)") + while (SystemClock.uptimeMillis() < deadline) { + val modal = UiWaitUtils.findElementOrNull(deleteAccountConfirmationModal) + val isVisible = modal != null && !modal.visibleBounds.isEmpty + if (!isVisible) { + return this + } + SystemClock.sleep(150) } - return this + + throw AssertionError("Delete account confirmation modal is still visible (expected it to be gone)") } fun selectBackupFileInDocumentsUI(teamHelper: TeamHelper, userAlias: String): SettingsPage { diff --git a/tests/testsSupport/src/main/call/CallHelper.kt b/tests/testsSupport/src/main/call/CallHelper.kt index 8e8168356bd..e9af8566851 100644 --- a/tests/testsSupport/src/main/call/CallHelper.kt +++ b/tests/testsSupport/src/main/call/CallHelper.kt @@ -101,4 +101,33 @@ class CallHelper { suspend fun userVerifiesAudio(callees: String) { callingManager.verifySendAndReceiveAudio(callees) } + + fun iSeeParticipantsInGroupVideoCall(participants: String) { + // 1. Resolve aliases into real usernames + val resolvedParticipants = usersManager.replaceAliasesOccurrences( + participants, + ClientUserManager.FindBy.NAME_ALIAS + ) + + // 2. Split into individual names and check each one + resolvedParticipants + .split(",") + .map { it.trim() } + .forEach { participant -> + try { + // In the video grid, each tile shows the participant name as a TextView label. + UiWaitUtils.waitElement( + UiSelectorParams( + text = participant + + ) + ) + } catch (e: AssertionError) { + throw AssertionError( + "User '$participant' is not visible in the ongoing group video call (name label not found).", + e + ) + } + } + } } diff --git a/tests/testsSupport/src/main/call/CallingManager.kt b/tests/testsSupport/src/main/call/CallingManager.kt index 178658ac638..805969609db 100644 --- a/tests/testsSupport/src/main/call/CallingManager.kt +++ b/tests/testsSupport/src/main/call/CallingManager.kt @@ -456,8 +456,19 @@ class CallingManager(private val usersManager: ClientUserManager) { userNames.forEach { name -> val user = usersManager.findUserByNameOrNameAlias(name) val flowsBefore = safeGetFlows(user) - for (flowBefore in flowsBefore) - assertPositiveFlowChange(user, flowBefore, audioRecv = true, videoRecv = true) + + check(flowsBefore.isNotEmpty()) { + "Found no flows for ${user.name}" + } + + for (flowBefore in flowsBefore) { + assertPositiveFlowChange( + user, + flowBefore, + audioRecv = true, + videoRecv = true + ) + } } } diff --git a/tests/testsSupport/src/main/res/raw/test.m4a b/tests/testsSupport/src/main/res/raw/test.m4a index ab94045950b..3937ef69258 100644 Binary files a/tests/testsSupport/src/main/res/raw/test.m4a and b/tests/testsSupport/src/main/res/raw/test.m4a differ diff --git a/tests/testsSupport/src/main/service/TestService.kt b/tests/testsSupport/src/main/service/TestService.kt index 49a5a966d64..42d0fa484eb 100644 --- a/tests/testsSupport/src/main/service/TestService.kt +++ b/tests/testsSupport/src/main/service/TestService.kt @@ -249,7 +249,9 @@ class TestService(private val baseUri: String, private val testName: String) { put("expectsReadConfirmation", true) } put("text", params.text) - put("buttons", params.buttons) + if (params.buttons.length() > 0) { + put("buttons", params.buttons) + } put("legalHoldStatus", params.legalHoldStatus) } val result = sendHttpRequest(connection, requestBody) @@ -699,7 +701,9 @@ class TestService(private val baseUri: String, private val testName: String) { } private fun JSONObject.addButtonsIfPresent(buttons: JSONArray?) { - buttons?.let { put("buttons", it) } + if (buttons != null && buttons.length() > 0) { + put("buttons", buttons) + } } private fun JSONObject.addMessageTimerIfNeeded(messageTimer: Duration) { diff --git a/tests/testsSupport/src/main/service/TestServiceHelper.kt b/tests/testsSupport/src/main/service/TestServiceHelper.kt index ac62801f555..2a04ca12818 100644 --- a/tests/testsSupport/src/main/service/TestServiceHelper.kt +++ b/tests/testsSupport/src/main/service/TestServiceHelper.kt @@ -65,12 +65,22 @@ class TestServiceHelper( } } + private fun backendFor(user: ClientUser): BackendClient { + val backendName = user.backendName + return if (backendName.isNullOrBlank()) { + BackendClient.getDefault() + ?: throw IllegalStateException("No default backend configured for user '${user.name}'.") + } else { + BackendClient.loadBackend(backendName) + } + } + fun getSelfDeletingMessageTimeout(userAlias: String, conversationName: String): Duration { val user = usersManager.findUserByNameOrNameAlias(userAlias) // Only team users support enforced self-deleting messages user.teamId?.let { - val settings = BackendClient.loadBackend(user.backendName.orEmpty()).getSelfDeletingMessagesSettings(user) + val settings = backendFor(user).getSelfDeletingMessagesSettings(user) if (settings.getString("status") == "enabled") { val timeoutInSeconds = settings @@ -87,20 +97,39 @@ class TestServiceHelper( } } - // Personal user or team user without set enforced self-deleting message setting - + // Personal user or team user without enforced setting val resolvedConversationName = usersManager.replaceAliasesOccurrences( conversationName, ClientUserManager.FindBy.NAME_ALIAS ) - val messageTimerMillis = toConvoObjPersonal(user, resolvedConversationName).messageTimerInMilliseconds - if (messageTimerMillis > 0) { - return Duration.ofMillis(messageTimerMillis.toLong()) + val conversationMessageTimerMillis = getConversationMessageTimer(user, resolvedConversationName) + if (conversationMessageTimerMillis > 0) { + return Duration.ofMillis(conversationMessageTimerMillis.toLong()) } - // Otherwise check for local/client-side self-deleting message timeout - return Duration.ofSeconds(Long.MAX_VALUE) + return Duration.ofMillis(Int.MAX_VALUE.toLong()) // ~24.8 days, safe int millis + } + + private fun getConversationMessageTimer(user: ClientUser, conversationName: String): Int { + val isPersonalConversationName = runCatching { + // If this succeeds, conversationName is a user name/alias (1:1 style like "user4Name") + usersManager.findUserByNameOrNameAlias(conversationName) + }.isSuccess + + val conversation = if (isPersonalConversationName) { + // Personal first, fallback to group just in case + runCatching { toConvoObjPersonal(user, conversationName) } + .recoverCatching { toConvoObj(user, conversationName) } + .getOrThrow() + } else { + // Group first, fallback to personal just in case + runCatching { toConvoObj(user, conversationName) } + .recoverCatching { toConvoObjPersonal(user, conversationName) } + .getOrThrow() + } + + return conversation.messageTimerInMilliseconds } fun contactSendsLocalAudioPersonalMLSConversation( @@ -133,6 +162,34 @@ class TestServiceHelper( ) } + fun contactSendsLocalAudioConversation( + context: Context, + fileName: String, + senderAlias: String, + deviceName: String, + dstConvoName: String + ) { + val audio = getRawResourceAsFile(context, R.raw.test, fileName) + val conversation = toConvoObj(toClientUser(senderAlias), dstConvoName) + + if (audio?.exists() != true) { + throw Exception("Audio file not found") + } + + val convoId = conversation.qualifiedID.id + val convoDomain = conversation.qualifiedID.domain + + testServiceClient.sendFile( + toClientUser(senderAlias), + deviceName, + convoId, + convoDomain, + getSelfDeletingMessageTimeout(senderAlias, dstConvoName), + audio.absolutePath.orEmpty(), + "audio/mp4" + ) + } + fun userXAddedContactsToGroupChat( userAsNameAlias: String, contactsToAddNameAliases: String, @@ -144,7 +201,7 @@ class TestServiceHelper( .splitAliases(contactsToAddNameAliases) .map { toClientUser(it) } - BackendClient.loadBackend(userAs.backendName.orEmpty()).addUsersToGroupConversation( + backendFor(userAs).addUsersToGroupConversation( asUser = userAs, contacts = contactsToAdd, conversation = toConvoObj(userAs, chatName) @@ -246,21 +303,22 @@ class TestServiceHelper( } fun toConvoObjPersonal(owner: ClientUser, convoName: String): Conversation { - val convoName = usersManager.replaceAliasesOccurrences(convoName, ClientUserManager.FindBy.NAME_ALIAS) - val backend = BackendClient.loadBackend(owner.backendName.orEmpty()) - return backend.getPersonalConversationByName(owner, convoName) + val seekName = usersManager.findUserByNameOrNameAlias(convoName).name + ?: throw NoSuchElementException("User '$convoName' does not have a resolvable display name.") + val backend = backendFor(owner) + return backend.getPersonalConversationByName(owner, seekName) } fun toConvoObj(owner: ClientUser, convoName: String): Conversation { val convoName = usersManager.replaceAliasesOccurrences(convoName, ClientUserManager.FindBy.NAME_ALIAS) - val backend = BackendClient.loadBackend(owner.backendName.orEmpty()) + val backend = backendFor(owner) return backend.getConversationByName(owner, convoName) } suspend fun usersSetUniqueUsername(userNameAliases: String) { usersManager.splitAliases(userNameAliases).forEach { userNameAlias -> val user = toClientUser(userNameAlias) - val backend = BackendClient.loadBackend(user.backendName.orEmpty()) + val backend = backendFor(user) backend.updateUniqueUsername( user, user.uniqueUsername.orEmpty() @@ -270,7 +328,7 @@ class TestServiceHelper( fun connectionRequestIsSentTo(userFromNameAlias: String, usersToNameAliases: String) { val userFrom = toClientUser(userFromNameAlias) - val backend = BackendClient.loadBackend(userFrom.backendName.orEmpty()) + val backend = backendFor(userFrom) val usersTo = usersManager .splitAliases(usersToNameAliases) .map(this::toClientUser) @@ -286,11 +344,11 @@ class TestServiceHelper( verificationCode: String? = null, deviceName: String? = null, ) { - val developmentApiEnabled = - BackendClient.loadBackend(toClientUser(ownerAlias).backendName.orEmpty()).isDevelopmentApiEnabled(toClientUser(ownerAlias)) + val owner = toClientUser(ownerAlias) + val developmentApiEnabled = backendFor(owner).isDevelopmentApiEnabled(owner) try { testServiceClient.login( - toClientUser(ownerAlias), + owner, verificationCode, deviceName, developmentApiEnabled @@ -299,7 +357,7 @@ class TestServiceHelper( try { TimeUnit.SECONDS.sleep(300) testServiceClient.login( - toClientUser(ownerAlias), + owner, verificationCode, deviceName, developmentApiEnabled @@ -324,21 +382,17 @@ class TestServiceHelper( .map(this::toClientUser) } - val backend = if (chatOwner.backendName.isNullOrEmpty()) { - BackendClient.getDefault() - } else { - BackendClient.loadBackend(chatOwner.backendName.orEmpty()) - } + val backend = backendFor(chatOwner) runBlocking { - val dstTeam = backend?.getTeamByName(chatOwner, teamName) - backend?.createTeamConversation(chatOwner, participants, chatName, dstTeam!!) + val dstTeam = backend.getTeamByName(chatOwner, teamName) + backend.createTeamConversation(chatOwner, participants, chatName, dstTeam) } } fun isSendReadReceiptEnabled(userNameAlias: String): Boolean { val user = toClientUser(userNameAlias) - val backend = BackendClient.loadBackend(user.backendName.orEmpty()) + val backend = backendFor(user) val json = runBlocking { backend.getPropertyValues(user) } @@ -375,7 +429,7 @@ class TestServiceHelper( fun syncUserIdsForUsersCreatedThroughIdP(ownerNameAlias: String, user: ClientUser) { user.getUserIdThroughOwner = Callable { val asUser = toClientUser(ownerNameAlias) - val backend = BackendClient.loadBackend(asUser.backendName.orEmpty()) + val backend = backendFor(asUser) val teamMembers = backend.getTeamMembers(asUser) for (member in teamMembers) { @@ -407,10 +461,16 @@ class TestServiceHelper( ) { val clientUser = toClientUser(senderAlias) val conversation = toConvoObj(clientUser, dstConvoName) - sendMessageInternal(clientUser, conversation, msg, deviceName, isSelfDeleting) + sendMessageInternal( + clientUser = clientUser, + conversation = conversation, + msg = msg, + deviceName = deviceName, + timeout = resolveMessageTimeout(senderAlias, dstConvoName, isSelfDeleting) + ) } - fun userSendMessageToConversationObj( + fun userSendMessageToPersonalMlsConversation( senderAlias: String, msg: String, deviceName: String, @@ -419,7 +479,27 @@ class TestServiceHelper( ) { val clientUser = toClientUser(senderAlias) val conversation = toConvoObjPersonal(clientUser, dstConvoName) - sendMessageInternal(clientUser, conversation, msg, deviceName, isSelfDeleting) + sendMessageInternal( + clientUser = clientUser, + conversation = conversation, + msg = msg, + deviceName = deviceName, + timeout = resolveMessageTimeout(senderAlias, dstConvoName, isSelfDeleting) + ) + } + + private fun resolveMessageTimeout( + senderAlias: String, + dstConvoName: String, + isSelfDeleting: Boolean + ): Duration { + return if (isSelfDeleting) { + Duration.ofSeconds(1000) + } else { + getSelfDeletingMessageTimeout(senderAlias, dstConvoName).let { timeout -> + if (timeout == Duration.ofMillis(Int.MAX_VALUE.toLong())) Duration.ZERO else timeout + } + } } fun userXSharesLocationTo( @@ -450,7 +530,7 @@ class TestServiceHelper( conversation: Conversation, msg: String, deviceName: String, - isSelfDeleting: Boolean + timeout: Duration ) { val convoId = conversation.qualifiedID.id val convoDomain = conversation.qualifiedID.domain @@ -461,17 +541,24 @@ class TestServiceHelper( else -> false } - testServiceClient.sendText( - SendTextParams( - owner = clientUser, - deviceName = deviceName, - convoDomain = convoDomain, - convoId = convoId, - timeout = if (isSelfDeleting) Duration.ofSeconds(1000) else Duration.ZERO, - expectsReadConfirmation = expReadConfirm, - text = msg, - legalHoldStatus = LegalHoldStatus.DISABLED.code, + try { + testServiceClient.sendText( + SendTextParams( + owner = clientUser, + deviceName = deviceName, + convoDomain = convoDomain, + convoId = convoId, + timeout = timeout, + expectsReadConfirmation = expReadConfirm, + text = msg, + legalHoldStatus = LegalHoldStatus.DISABLED.code, + ) ) - ) + } catch (e: Throwable) { + throw AssertionError( + "Failed to send message '$msg' to conversationId='$convoId' for user='${clientUser.name}' on device '$deviceName'.", + e + ) + } } } diff --git a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt index fe2a76a0990..abb8b1a3d04 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt @@ -1,6 +1,9 @@ +import android.content.ContentValues import android.graphics.Bitmap -import android.os.Environment import android.graphics.Color +import android.os.Build +import android.os.Environment +import android.provider.MediaStore import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import com.google.zxing.BarcodeFormat @@ -38,10 +41,14 @@ fun deleteDownloadedFilesContaining(keyword: String, dir: String = DOWNLOAD_DIR) } } -@Suppress("MagicNumber") +@Suppress("MagicNumber", "ThrowsCount", "TooGenericExceptionCaught") object QrCodeTestUtils { + /** + * Generates a QR PNG and stores it in the device Downloads folder so test flows can pick it from DocumentsUI. + */ fun createQrImageInDeviceDownloadsFolder(text: String): File { val size = 500 + val fileName = "$text.png" val bitMatrix = QRCodeWriter().encode( text, BarcodeFormat.QR_CODE, @@ -55,17 +62,45 @@ object QrCodeTestUtils { } } - val downloads = Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DOWNLOADS - ) + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) if (!downloads.exists()) downloads.mkdirs() - val file = File(downloads, "$text.png") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Android Q+ requires writing shared files through MediaStore (scoped storage). + val resolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "image/png") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + // Keep the item hidden until writing is complete. + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: throw IOException("Failed to create MediaStore entry for $fileName") + + try { + resolver.openOutputStream(uri)?.use { output -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) + } ?: throw IOException("Failed to open output stream for $uri") + // Publish file to Downloads once fully written. + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } catch (e: Exception) { + // Avoid leaving broken entries in MediaStore on partial write failures. + resolver.delete(uri, null, null) + throw e + } + + return File(downloads, fileName) + } + + val file = File(downloads, fileName) FileOutputStream(file).use { fos -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos) } - return file } } diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index f251e3f2a67..4925f45bad2 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt @@ -19,6 +19,7 @@ package uiautomatorutils import android.graphics.Rect import android.os.SystemClock +import android.view.accessibility.AccessibilityEvent import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.BySelector @@ -29,6 +30,8 @@ import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import java.io.IOException import java.util.regex.Pattern +import junit.framework.TestCase.assertTrue + private const val TIMEOUT_IN_MILLISECONDS = 10000L data class UiSelectorParams( @@ -100,7 +103,7 @@ object UiWaitUtils { device.waitForIdle(500) // 2) Stabilize: refetch until bounds are stable & usable - val end = SystemClock.uptimeMillis() + 1_500 + val end = SystemClock.uptimeMillis() + 3_000 var lastBounds: Rect? = null while (SystemClock.uptimeMillis() < end) { @@ -175,4 +178,74 @@ object UiWaitUtils { } } } + + fun waitUntilVisible( + params: UiSelectorParams, + timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, + errorMessage: String + ) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + try { + val sel = params.toBySelector() + if (!device.wait(Until.hasObject(sel), timeoutMs)) { + throw AssertionError() + } + } catch (e: AssertionError) { + throw AssertionError(errorMessage, e) + } + } + + fun waitUntilToastIsDisplayed( + message: String, + timeoutMs: Long = 5_000 + ) { + waitUntilVisible( + params = UiSelectorParams(textContains = message), + timeoutMs = timeoutMs, + errorMessage = "Toast message containing '$message' was not displayed within ${timeoutMs}ms." + ) + } + + fun iSeeSystemMessage( + message: String, + timeoutMs: Long = 5_000 + ) { + waitUntilVisible( + params = UiSelectorParams(textContains = message), + timeoutMs = timeoutMs, + errorMessage = "System message containing '$message' was not displayed within ${timeoutMs}ms." + ) + } + + @Suppress("MagicNumber") + fun assertToastDisplayed(text: String, trigger: () -> Unit, timeoutMs: Long = 5_000L) { + var toastDisplayed = false + val startTimeMs = System.currentTimeMillis() + + val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation + + uiAutomation.setOnAccessibilityEventListener { event -> + if (event.eventType == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED) { + val className = event.className?.toString().orEmpty() + val eventText = event.text?.joinToString(" ").orEmpty() + + if (className.contains("android.widget.Toast") && eventText.contains(text, ignoreCase = true)) { + toastDisplayed = true + } + } + } + + try { + // IMPORTANT: trigger AFTER listener is set + trigger() + + while (!toastDisplayed && System.currentTimeMillis() - startTimeMs < timeoutMs) { + Thread.sleep(50) + } + + assertTrue("Toast with text '$text' not found within ${timeoutMs}ms", toastDisplayed) + } finally { + uiAutomation.setOnAccessibilityEventListener(null) + } + } }