From 847201b090a8d6973e9035051f93edfa55b55b45 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:49:50 +0200 Subject: [PATCH 1/2] test(cells): add S3 transfer e2e coverage --- .../core/e2eTests/CellsS3TransferTest.kt | 282 ++++++++++++++++++ .../wire/android/tests/core/pages/AllPages.kt | 1 + .../android/tests/core/pages/CellsPage.kt | 66 ++++ .../tests/core/pages/ConversationViewPage.kt | 6 +- .../tests/core/pages/DocumentsUIPage.kt | 21 +- .../pages/GroupConversationDetailsPage.kt | 7 + .../main/backendUtils/BackendSetupHelper.kt | 14 +- .../BackendConversationExtensions.kt | 4 +- .../team/BackendTeamExtensions.kt | 17 ++ .../src/main/uiautomatorutils/FileUtils.kt | 161 ++++++++++ 10 files changed, 565 insertions(+), 14 deletions(-) create mode 100644 tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/CellsS3TransferTest.kt create mode 100644 tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CellsPage.kt diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/CellsS3TransferTest.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/CellsS3TransferTest.kt new file mode 100644 index 00000000000..e7e45bcd4e4 --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/CellsS3TransferTest.kt @@ -0,0 +1,282 @@ +/* + * 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.e2eTests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import backendUtils.team.TeamRoles +import com.wire.android.tests.core.BaseUiTest +import com.wire.android.tests.support.UiAutomatorSetup +import com.wire.android.tests.support.tags.Category +import createDeterministicFileInDeviceDownloadsFolder +import deleteAppExternalFilesContaining +import deleteDownloadedFilesContaining +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import user.usermanager.ClientUserManager +import user.utils.ClientUser +import waitUntilAppExternalFileIsAbsent +import waitUntilAppExternalFileIsPartial +import waitUntilAppExternalFileMatches +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +class CellsS3TransferTest : BaseUiTest() { + private lateinit var sender: ClientUser + private lateinit var receiver: ClientUser + + @Before + fun setUp() { + initCommonTestHelpers() + device = UiAutomatorSetup.start(UiAutomatorSetup.APP_ALPHA) + } + + @After + fun tearDown() { + deleteDownloadedFilesContaining(FILE_PREFIX) + deleteAppExternalFilesContaining(UiAutomatorSetup.appPackage, FILE_PREFIX) + } + + @Category("cells", "regression", "RC") + @Test + fun givenSmallCellsAttachment_whenReceiverDownloadsIt_thenContentMatchesExactly() { + val conversationName = "Cells S3 small transfer" + val testFile = createDeterministicFileInDeviceDownloadsFolder( + fileName = "$FILE_PREFIX-small ΓΌ.bin", + size = SMALL_FILE_SIZE, + ) + + step("Prepare a Cells-enabled team conversation with sender and receiver") { + prepareCellsConversation(conversationName) + } + + step("Login receiver first, add sender account, and remain on sender account") { + loginUser(receiver) + openAddAccountFlow() + loginUser(sender) + } + + step("Upload and publish the small attachment from the sender account") { + openConversation(conversationName) + selectFileFromDownloads(testFile.fileName) + pages.conversationViewPage.apply { + assertFileWithNameIsVisible(testFile.fileName) + clickSendButton(timeout = SMALL_UPLOAD_TIMEOUT) + assertFileWithNameIsVisible(testFile.fileName) + tapBackButtonToCloseConversationViewPage() + } + } + + step("Switch to receiver and start a genuine remote download from Shared Drive") { + switchToAccount(receiver) + openSharedDrive(conversationName) + waitUntilAppExternalFileIsAbsent( + appPackage = UiAutomatorSetup.appPackage, + fileName = testFile.fileName, + timeout = 5.seconds, + ) + pages.cellsPage.tapFile(testFile.fileName) + } + + step("Verify the remotely downloaded bytes, size, and SHA-256") { + waitUntilAppExternalFileMatches( + appPackage = UiAutomatorSetup.appPackage, + expected = testFile, + timeout = SMALL_DOWNLOAD_TIMEOUT, + ) + } + } + + @Suppress("LongMethod") + @Category("cells", "regression", "RC") + @Test + fun givenMultipartCellsAttachment_whenDownloadIsCancelledAndRetried_thenContentMatchesExactly() { + val conversationName = "Cells S3 multipart transfer" + val testFile = createDeterministicFileInDeviceDownloadsFolder( + fileName = "$FILE_PREFIX-multipart.bin", + size = MULTIPART_FILE_SIZE, + ) + + step("Prepare a Cells-enabled team conversation with sender and receiver") { + prepareCellsConversation(conversationName) + } + + step("Login receiver first, add sender account, and remain on sender account") { + loginUser(receiver) + openAddAccountFlow() + loginUser(sender) + } + + step("Upload and publish a file one byte above the multipart threshold") { + openConversation(conversationName) + selectFileFromDownloads(testFile.fileName) + pages.conversationViewPage.apply { + assertFileWithNameIsVisible(testFile.fileName) + clickSendButton(timeout = MULTIPART_UPLOAD_TIMEOUT) + assertFileWithNameIsVisible(testFile.fileName) + tapBackButtonToCloseConversationViewPage() + } + } + + step("Switch to receiver and open the conversation Shared Drive") { + switchToAccount(receiver) + openSharedDrive(conversationName) + } + + step("Start the direct download, cancel it, and verify the partial file is deleted") { + pages.cellsPage.apply { + tapFile(testFile.fileName) + assertDownloadStarted(timeout = 30.seconds) + } + waitUntilAppExternalFileIsPartial( + appPackage = UiAutomatorSetup.appPackage, + expected = testFile, + timeout = 30.seconds, + ) + pages.cellsPage.apply { + cancelActiveDownload() + assertDownloadCancelled() + } + waitUntilAppExternalFileIsAbsent( + appPackage = UiAutomatorSetup.appPackage, + fileName = testFile.fileName, + timeout = 30.seconds, + ) + } + + step("Retry the direct download and wait until it is ready") { + pages.cellsPage.apply { + tapFile(testFile.fileName) + assertDownloadStarted(timeout = 30.seconds) + assertFileReadyToOpen(timeout = MULTIPART_DOWNLOAD_TIMEOUT) + } + } + + step("Verify the multipart round-trip bytes, size, and SHA-256") { + waitUntilAppExternalFileMatches( + appPackage = UiAutomatorSetup.appPackage, + expected = testFile, + timeout = 2.minutes, + ) + } + } + + private fun prepareCellsConversation(conversationName: String) { + backendSetupHelper.createTeamOwnerByAlias( + "user1Name", + TEAM_NAME, + "en_US", + true, + backendClient, + context, + ) + backendSetupHelper.userXAddsUsersToTeam( + "user1Name", + "user2Name", + TEAM_NAME, + TeamRoles.Member, + backendClient, + context, + true, + ) + backendSetupHelper.enableCellsFeature("user1Name", TEAM_NAME, backendClient) + backendSetupHelper.userHasGroupConversationInTeam( + chatOwnerNameAlias = "user1Name", + chatName = conversationName, + otherParticipantsNameAlises = "user2Name", + teamName = TEAM_NAME, + cellsEnabled = true, + ) + sender = clientUserManager.findUserBy("user1Name", ClientUserManager.FindBy.NAME_ALIAS) + receiver = clientUserManager.findUserBy("user2Name", ClientUserManager.FindBy.NAME_ALIAS) + } + + private fun loginUser(user: ClientUser) { + pages.loginPage.apply { + clickStagingDeepLink() + clickProceedButtonOnDeeplinkOverlay() + enterUserIdentifier(user.email.orEmpty()) + clickLoginButton() + enterUserPassword(user.password.orEmpty()) + clickLoginButton() + } + pages.registrationPage.apply { + waitUntilLoginFlowIsCompleted() + clickAllowNotificationButton() + clickDeclineShareDataAlert() + assertConversationPageVisible() + } + } + + private fun openAddAccountFlow() { + pages.conversationListPage.clickUserProfileButton() + pages.selfUserProfilePage.apply { + iSeeUserProfilePage() + tapNewAccountButton() + } + } + + private fun switchToAccount(user: ClientUser) { + pages.conversationListPage.clickUserProfileButton() + pages.selfUserProfilePage.apply { + iSeeUserProfilePage() + tapOtherAccountByName(user.name.orEmpty()) + } + pages.conversationListPage.assertConversationListVisible() + } + + private fun openConversation(conversationName: String) { + pages.conversationListPage.clickGroupConversation(conversationName, timeout = 2.minutes) + pages.conversationViewPage.assertGroupConversationInForeground(conversationName) + } + + private fun selectFileFromDownloads(fileName: String) { + pages.conversationViewPage.apply { + iTapFileSharingButton() + assertSharingOptionVisible("File") + tapSharingOption("File") + } + pages.documentsUIPage.apply { + iSeeFile(fileName) + iOpenDisplayedFile(fileName) + } + } + + private fun openSharedDrive(conversationName: String) { + openConversation(conversationName) + pages.conversationViewPage.clickOnGroupConversationDetails(conversationName) + pages.groupConversationDetailsPage.apply { + assertGroupDetailsPageVisible() + tapSharedDriveButton() + } + pages.cellsPage.assertSharedDriveVisible() + } + + private companion object { + const val TEAM_NAME = "Cells S3 E2E" + const val FILE_PREFIX = "cells-s3-e2e" + const val SMALL_FILE_SIZE = 2L * 1024L * 1024L + const val MULTIPART_FILE_SIZE = 100L * 1024L * 1024L + 1L + val SMALL_UPLOAD_TIMEOUT = 3.minutes + val SMALL_DOWNLOAD_TIMEOUT = 2.minutes + val MULTIPART_UPLOAD_TIMEOUT = 15.minutes + val MULTIPART_DOWNLOAD_TIMEOUT = 10.minutes + } +} diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/AllPages.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/AllPages.kt index f851104e1c4..e46b428f40b 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/AllPages.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/AllPages.kt @@ -51,4 +51,5 @@ class AllPages(val device: UiDevice) { val callingPage = CallingPage(device) val documentsUIPage = DocumentsUIPage(device) + val cellsPage = CellsPage(device) } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CellsPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CellsPage.kt new file mode 100644 index 00000000000..e63c442dbf2 --- /dev/null +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/CellsPage.kt @@ -0,0 +1,66 @@ +/* + * 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.pages + +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import uiautomatorutils.UiSelectorParams +import uiautomatorutils.UiWaitUtils +import kotlin.time.Duration + +data class CellsPage(private val device: UiDevice) { + private val sharedDriveTitle = UiSelectorParams(text = "Shared Drive") + private val cancelLoadingLabel = UiSelectorParams(text = "Tap to cancel loading") + private val readyToOpenLabel = UiSelectorParams(text = "Ready to open") + + fun assertSharedDriveVisible(timeout: Duration = UiWaitUtils.VERY_LONG_TIMEOUT): CellsPage { + UiWaitUtils.waitElement(sharedDriveTitle, timeout) + return this + } + + fun tapFile(fileName: String, timeout: Duration = UiWaitUtils.VERY_LONG_TIMEOUT): CellsPage { + if (!UiWaitUtils.clickWhenClickable(UiSelectorParams(text = fileName), timeout)) { + throw AssertionError("Cells file '$fileName' was not clickable within ${timeout.inWholeMilliseconds}ms.") + } + return this + } + + fun assertDownloadStarted(timeout: Duration = UiWaitUtils.VERY_LONG_TIMEOUT): CellsPage { + UiWaitUtils.waitElement(cancelLoadingLabel, timeout) + return this + } + + fun cancelActiveDownload(): CellsPage { + UiWaitUtils.waitElement(cancelLoadingLabel).click() + return this + } + + fun assertDownloadCancelled(timeout: Duration = UiWaitUtils.VERY_LONG_TIMEOUT): CellsPage { + UiWaitUtils.waitUntilGoneOrThrow( + selector = By.text("Tap to cancel loading"), + timeout = timeout, + errorMessage = "Cells download remained active after cancellation." + ) + return this + } + + fun assertFileReadyToOpen(timeout: Duration): CellsPage { + UiWaitUtils.waitElement(readyToOpenLabel, timeout) + 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 1c19526937d..91c7f67bab5 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 @@ -451,8 +451,10 @@ data class ConversationViewPage(private val device: UiDevice) { return this } - fun clickSendButton(): ConversationViewPage { - UiWaitUtils.waitElement(sendButton).click() + fun clickSendButton(timeout: Duration = UiWaitUtils.DEFAULT_TIMEOUT): ConversationViewPage { + if (!UiWaitUtils.clickWhenClickable(sendButton, timeout = timeout)) { + throw AssertionError("Send button was not enabled within ${timeout.inWholeMilliseconds}ms.") + } 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 11ddf99ef07..b2b2aa8a5e6 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 @@ -28,11 +28,11 @@ data class DocumentsUIPage(private val device: UiDevice) { 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) + fun iSeeFile(fileName: String): DocumentsUIPage { + val file = UiSelectorParams(text = fileName) - // Picker may open in Recent folder; switch to Downloads so the generated QR file is visible. - if (UiWaitUtils.findElementOrNull(qrCodeImage) == null) { + // Picker may open in Recent folder; switch to Downloads so the generated test file is visible. + if (UiWaitUtils.findElementOrNull(file) == null) { if (!clickWithRetry(downloadsOption)) { clickWithRetry(showRootsButton) clickWithRetry(downloadsOption) @@ -40,13 +40,15 @@ data class DocumentsUIPage(private val device: UiDevice) { } try { - UiWaitUtils.waitElement(qrCodeImage) + UiWaitUtils.waitElement(file) } catch (e: AssertionError) { - throw AssertionError("QR code '$fileName' is not visible", e) + throw AssertionError("File '$fileName' is not visible", e) } return this } + fun iSeeQrCodeImage(fileName: String = "my-test-qr.png"): DocumentsUIPage = iSeeFile(fileName) + private fun clickWithRetry(selector: UiSelectorParams, attempts: Int = 3): Boolean { repeat(attempts) { try { @@ -61,12 +63,13 @@ data class DocumentsUIPage(private val device: UiDevice) { return false } - fun iOpenDisplayedQrCodeImage(fileName: String = "my-test-qr.png"): DocumentsUIPage { - val qrCodeImage = UiSelectorParams(text = fileName) - UiWaitUtils.waitElement(qrCodeImage).click() + fun iOpenDisplayedFile(fileName: String): DocumentsUIPage { + UiWaitUtils.waitElement(UiSelectorParams(text = fileName)).click() return this } + fun iOpenDisplayedQrCodeImage(fileName: String = "my-test-qr.png"): DocumentsUIPage = iOpenDisplayedFile(fileName) + fun iTapSendButtonOnPreviewImage(): DocumentsUIPage { UiWaitUtils.waitElement(sendButton).click() 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 0ec71c53e16..6fa300cb7ea 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 @@ -50,6 +50,8 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { private val conversationDetailsHeading = UiSelectorParams(text = "Conversation Details") + private val sharedDriveButton = UiSelectorParams(description = "Open Shared Drive") + private val removeFromConversationButton = UiSelectorParams(text = "Remove From Conversation") private val addToConversationButton = UiSelectorParams(text = "Add To Conversation") @@ -68,6 +70,11 @@ data class GroupConversationDetailsPage(private val device: UiDevice) { return this } + fun tapSharedDriveButton(): GroupConversationDetailsPage { + UiWaitUtils.waitElement(sharedDriveButton).click() + return this + } + fun tapShowMoreOptionsButton() { UiWaitUtils.waitElement(showMoreOptionsButton).click() } diff --git a/tests/testsSupport/src/main/backendUtils/BackendSetupHelper.kt b/tests/testsSupport/src/main/backendUtils/BackendSetupHelper.kt index ba126249e96..a3d938073b9 100644 --- a/tests/testsSupport/src/main/backendUtils/BackendSetupHelper.kt +++ b/tests/testsSupport/src/main/backendUtils/BackendSetupHelper.kt @@ -30,6 +30,7 @@ import backendUtils.conversation.removeUserFromGroupConversation import backendUtils.conversation.setArchivedStateForConversation import backendUtils.team.addServiceToConversation import backendUtils.team.enableChannelFeatureViaBackdoorTeam +import backendUtils.team.enableCellsFeatureViaBackdoorTeam import backendUtils.team.enableForceAppLockFeature import backendUtils.team.enableMLSFeatureTeam import backendUtils.team.getTeamByName @@ -270,7 +271,8 @@ class BackendSetupHelper( chatOwnerNameAlias: String, chatName: String? = null, otherParticipantsNameAlises: String? = null, - teamName: String + teamName: String, + cellsEnabled: Boolean = false, ) { var participants: List? = null val chatOwner = toClientUser(chatOwnerNameAlias) @@ -284,7 +286,15 @@ class BackendSetupHelper( runBlocking { val dstTeam = backend.getTeamByName(chatOwner, teamName) - backend.createTeamConversation(chatOwner, participants, chatName, dstTeam) + backend.createTeamConversation(chatOwner, participants, chatName, dstTeam, cellsEnabled) + } + } + + fun enableCellsFeature(ownerUserAlias: String, teamName: String, backendClient: BackendClient) { + val owner = toClientUser(ownerUserAlias) + runBlocking { + val team = backendClient.getTeamByName(owner, teamName) + backendClient.enableCellsFeatureViaBackdoorTeam(team) } } diff --git a/tests/testsSupport/src/main/backendUtils/conversation/BackendConversationExtensions.kt b/tests/testsSupport/src/main/backendUtils/conversation/BackendConversationExtensions.kt index 24baddc485b..529865002b2 100644 --- a/tests/testsSupport/src/main/backendUtils/conversation/BackendConversationExtensions.kt +++ b/tests/testsSupport/src/main/backendUtils/conversation/BackendConversationExtensions.kt @@ -46,7 +46,8 @@ suspend fun BackendClient.createTeamConversation( user: ClientUser, contacts: List?, conversationName: String?, - team: Team + team: Team, + cellsEnabled: Boolean = false, ): String { val token = getAuthToken(user) val url = URI("conversations".composeCompleteUrl()).toURL() @@ -70,6 +71,7 @@ suspend fun BackendClient.createTeamConversation( } ) put("conversation_role", "wire_member") + if (cellsEnabled) put("cells", true) conversationName?.let { put("name", it) } put( "team", diff --git a/tests/testsSupport/src/main/backendUtils/team/BackendTeamExtensions.kt b/tests/testsSupport/src/main/backendUtils/team/BackendTeamExtensions.kt index 308ae36b443..a166bb0de1a 100644 --- a/tests/testsSupport/src/main/backendUtils/team/BackendTeamExtensions.kt +++ b/tests/testsSupport/src/main/backendUtils/team/BackendTeamExtensions.kt @@ -616,6 +616,23 @@ suspend fun BackendClient.enableChannelFeatureViaBackdoorTeam(team: Team) { ) } +suspend fun BackendClient.enableCellsFeatureViaBackdoorTeam(team: Team) { + val teamId = Uri.encode(team.id) + val headers = defaultheaders.toMutableMap().apply { + put("Authorization", basicAuth.getEncoded()) + } + + NetworkBackendClient.sendJsonRequestWithCookies( + url = URI("i/teams/$teamId/features/cells".composeCompleteUrl()).toURL(), + method = "PATCH", + headers = headers, + body = JSONObject().put("status", "enabled").toString(), + options = RequestOptions( + expectedResponseCodes = NumberSequence.Array(intArrayOf(HttpURLConnection.HTTP_OK)) + ) + ) +} + suspend fun BackendClient.unlockChannelFeature(team: Team) { val teamId = Uri.encode(team.id) val url = URI("i/teams/$teamId/features/channels/unlocked".composeCompleteUrl()).toURL() diff --git a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt index abb8b1a3d04..0b9147bd544 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt @@ -3,6 +3,7 @@ import android.graphics.Bitmap import android.graphics.Color import android.os.Build import android.os.Environment +import android.os.SystemClock import android.provider.MediaStore import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice @@ -11,9 +12,169 @@ import com.google.zxing.qrcode.QRCodeWriter import java.io.File import java.io.FileOutputStream import java.io.IOException +import java.security.MessageDigest +import kotlin.time.Duration private const val DOWNLOAD_DIR = "/sdcard/Download" +data class DeviceTestFile( + val fileName: String, + val size: Long, + val sha256: String, +) + +data class DeviceFileFingerprint( + val path: String, + val size: Long, + val sha256: String, +) + +@Suppress("MagicNumber", "NestedBlockDepth") +fun createDeterministicFileInDeviceDownloadsFolder(fileName: String, size: Long): DeviceTestFile { + require(size >= 0) { "File size must be non-negative." } + val resolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!downloads.exists()) downloads.mkdirs() + + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(64 * 1024) { index -> ((index * 31 + 17) % 251).toByte() } + + fun writeContent(output: java.io.OutputStream) { + var remaining = size + while (remaining > 0) { + val count = minOf(remaining, buffer.size.toLong()).toInt() + output.write(buffer, 0, count) + digest.update(buffer, 0, count) + remaining -= count + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + resolver.delete( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + "${MediaStore.MediaColumns.DISPLAY_NAME} = ?", + arrayOf(fileName), + ) + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "application/octet-stream") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + 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(::writeContent) + ?: throw IOException("Failed to open output stream for $uri") + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } catch (error: Exception) { + resolver.delete(uri, null, null) + throw error + } + } else { + FileOutputStream(File(downloads, fileName)).use(::writeContent) + } + + return DeviceTestFile(fileName, size, digest.digest().toHexString()) +} + +fun waitUntilAppExternalFileMatches( + appPackage: String, + expected: DeviceTestFile, + timeout: Duration, +): DeviceFileFingerprint { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val deadline = SystemClock.uptimeMillis() + timeout.inWholeMilliseconds + var lastObserved: String? = null + + while (SystemClock.uptimeMillis() < deadline) { + val paths = findAppExternalFiles(device, appPackage, expected.fileName) + for (path in paths) { + val size = device.executeShellCommand("stat -c %s ${path.shellQuoted()}").trim().toLongOrNull() + ?: continue + lastObserved = "$path ($size bytes)" + if (size == expected.size) { + val sha256 = device.executeShellCommand("sha256sum ${path.shellQuoted()}") + .trim() + .substringBefore(' ') + if (sha256 == expected.sha256) { + return DeviceFileFingerprint(path, size, sha256) + } + lastObserved = "$path ($size bytes, sha256=$sha256)" + } + } + SystemClock.sleep(500) + } + + throw AssertionError( + "No downloaded file '${expected.fileName}' matched size=${expected.size} and " + + "sha256=${expected.sha256} within ${timeout.inWholeMilliseconds}ms. Last observed: $lastObserved" + ) +} + +fun waitUntilAppExternalFileIsAbsent( + appPackage: String, + fileName: String, + timeout: Duration, +) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val deadline = SystemClock.uptimeMillis() + timeout.inWholeMilliseconds + while (SystemClock.uptimeMillis() < deadline) { + if (findAppExternalFiles(device, appPackage, fileName).isEmpty()) return + SystemClock.sleep(250) + } + throw AssertionError("Partial download '$fileName' was not removed within ${timeout.inWholeMilliseconds}ms.") +} + +fun waitUntilAppExternalFileIsPartial( + appPackage: String, + expected: DeviceTestFile, + timeout: Duration, +) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val deadline = SystemClock.uptimeMillis() + timeout.inWholeMilliseconds + + while (SystemClock.uptimeMillis() < deadline) { + findAppExternalFiles(device, appPackage, expected.fileName).forEach { path -> + val size = device.executeShellCommand("stat -c %s ${path.shellQuoted()}").trim().toLongOrNull() + ?: return@forEach + if (size in 1 until expected.size) { + return + } + } + SystemClock.sleep(100) + } + + throw AssertionError( + "Download '${expected.fileName}' did not produce a partial file within ${timeout.inWholeMilliseconds}ms." + ) +} + +fun deleteAppExternalFilesContaining(appPackage: String, keyword: String) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val root = "/sdcard/Android/data/$appPackage/files" + device.executeShellCommand("find ${root.shellQuoted()} -type f") + .lineSequence() + .map(String::trim) + .filter { path -> path.startsWith(root) && path.substringAfterLast('/').contains(keyword, ignoreCase = true) } + .forEach { path -> device.executeShellCommand("rm -f ${path.shellQuoted()}") } +} + +private fun findAppExternalFiles(device: UiDevice, appPackage: String, fileName: String): List { + val root = "/sdcard/Android/data/$appPackage/files" + return device.executeShellCommand( + "find ${root.shellQuoted()} -type f -name ${fileName.shellQuoted()}" + ).lineSequence().map(String::trim).filter { it.startsWith(root) }.toList() +} + +private fun ByteArray.toHexString(): String = joinToString(separator = "") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') +} + +private fun String.shellQuoted(): String = "'${replace("'", "'\\''")}'" + fun deleteDownloadedFilesContaining(keyword: String, dir: String = DOWNLOAD_DIR) { try { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) From 43d09005f7a2c91d9529ca48181203f199b25085 Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Mon, 13 Jul 2026 22:03:25 +0200 Subject: [PATCH 2/2] test: stabilize login flow fix file cleanup --- .../core/e2eTests/CellsS3TransferTest.kt | 3 +- .../tests/core/pages/ConversationListPage.kt | 12 +++++++ .../src/main/uiautomatorutils/FileUtils.kt | 34 +++++++------------ 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/CellsS3TransferTest.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/CellsS3TransferTest.kt index e7e45bcd4e4..625e3489cba 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/CellsS3TransferTest.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/e2eTests/CellsS3TransferTest.kt @@ -69,6 +69,7 @@ class CellsS3TransferTest : BaseUiTest() { step("Login receiver first, add sender account, and remain on sender account") { loginUser(receiver) + pages.conversationListPage.waitUntilWireServiceNotificationDisappears() openAddAccountFlow() loginUser(sender) } @@ -78,7 +79,7 @@ class CellsS3TransferTest : BaseUiTest() { selectFileFromDownloads(testFile.fileName) pages.conversationViewPage.apply { assertFileWithNameIsVisible(testFile.fileName) - clickSendButton(timeout = SMALL_UPLOAD_TIMEOUT) + pages.documentsUIPage.iTapSendButtonOnPreviewImage() assertFileWithNameIsVisible(testFile.fileName) tapBackButtonToCloseConversationViewPage() } 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 4efd9348865..eecacbc3967 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 @@ -61,6 +61,7 @@ data class ConversationListPage(private val device: UiDevice) { UiSelectorParams(textContains = "You will then no longer be able to send or read messages") private val startNewConversation = UiSelectorParams(description = "New. Start a new conversation") private val createNewChannelButton = UiSelectorParams(text = "New Channel") + private val wireServiceNotification = UiSelectorParams(textContains = "Wire Alpha service is running") private val blockedLabel = UiSelectorParams(text = "Blocked") private val blockOption = UiSelectorParams(text = "Block") private val unblockOption = UiSelectorParams(text = "Unblock") @@ -81,6 +82,17 @@ data class ConversationListPage(private val device: UiDevice) { return this } + fun waitUntilWireServiceNotificationDisappears( + timeout: Duration = UiWaitUtils.VERY_LONG_TIMEOUT + ): ConversationListPage { + UiWaitUtils.waitUntilGoneOrThrow( + selector = wireServiceNotification.toBySelector(), + timeout = timeout, + errorMessage = "Wire service notification is still visible." + ) + return this + } + fun assertConversationListNotVisible(): ConversationListPage { val heading = findElementOrNull(conversationListHeading) Assert.assertTrue( diff --git a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt index 0b9147bd544..e73c05ec708 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/FileUtils.kt @@ -176,29 +176,19 @@ private fun ByteArray.toHexString(): String = joinToString(separator = "") { byt private fun String.shellQuoted(): String = "'${replace("'", "'\\''")}'" fun deleteDownloadedFilesContaining(keyword: String, dir: String = DOWNLOAD_DIR) { - try { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val listCommand = "ls $dir" - val fileListOutput = device.executeShellCommand(listCommand) - - val matchingFiles = fileListOutput - .split("\n") - .map { it.trim() } - .filter { it.contains(keyword, ignoreCase = true) } - - if (matchingFiles.isEmpty()) { - println("No files found containing '$keyword'") - return - } + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - for (file in matchingFiles) { - if (file.isBlank()) continue - val deleteCommand = "rm -f $dir/$file" - val result = device.executeShellCommand(deleteCommand) - println("Deleted: $file. Output: $result") - } - } catch (e: IOException) { - println("Error while deleting files: ${e.message}") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val downloadsUri = "content://media/external/downloads" + device.executeShellCommand("content query --uri $downloadsUri --projection _id:_display_name") + .lineSequence() + .filter { it.substringAfter("_display_name=").contains(keyword, ignoreCase = true) } + .mapNotNull { Regex("_id=(\\d+)").find(it)?.groupValues?.get(1) } + .forEach { id -> device.executeShellCommand("content delete --uri $downloadsUri/$id") } + } else { + device.executeShellCommand( + "find ${dir.shellQuoted()} -maxdepth 1 -type f -iname ${"*$keyword*".shellQuoted()} -delete" + ) } }