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..5c758591aa3 --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt @@ -0,0 +1,471 @@ +/* + * 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() + } + } + + step("And I play audio message") { + pages.conversationViewPage.apply { + waitFor(1) + clickPlayButtonOnAudioMessage() + } + } + + step("And I pause audio message after 5 seconds") { + pages.conversationViewPage.apply { + waitFor(5) + 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/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/ConversationViewPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt index 32feb0e6601..e6bbc9fe729 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 @@ -64,6 +64,7 @@ data class ConversationViewPage(private val device: UiDevice) { private val messageInputField = UiSelectorParams(className = "android.widget.EditText") 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") @@ -72,6 +73,14 @@ data class ConversationViewPage(private val device: UiDevice) { private val selfDeleteTimerButton = UiSelectorParams(description = "Set timer for self-deleting messages") 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 val mlsUpgradeMessageSelectors = listOf( UiSelectorParams(textContains = "This conversation now uses the new Messaging"), @@ -455,6 +464,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 @@ -530,4 +552,26 @@ data class ConversationViewPage(private val device: UiDevice) { throw AssertionError("MLS upgrade system message was not visible within ${timeoutMs}ms.") } + + 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/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/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