/* * Copyright (C) 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.privatespace.integration import android.content.ComponentName import android.content.Context import android.content.Intent import android.multiuser.Flags import android.os.Environment import android.os.UserHandle import android.platform.test.annotations.RequiresFlagsEnabled import android.platform.test.flag.junit.CheckFlagsRule import android.platform.test.flag.junit.DeviceFlagsValueProvider import android.util.Log import androidx.compose.ui.semantics.text import androidx.test.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import com.android.bedstead.harrier.BedsteadJUnit4 import com.android.bedstead.harrier.annotations.NotificationsTest import com.android.bedstead.multiuser.annotations.RequireRunOnPrivateProfile import com.android.bedstead.nene.TestApis import com.android.bedstead.nene.exceptions.AdbException import com.android.bedstead.nene.notifications.NotificationListener import com.android.bedstead.nene.notifications.NotificationListenerQuerySubject import com.android.bedstead.nene.utils.ShellCommand import com.android.privatespace.PrivateSpaceActivity import com.android.privatespace.PrivateSpaceActivity.Companion.ACTION_ADD_FILES import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage import java.io.File import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RequiresFlagsEnabled(Flags.FLAG_ENABLE_MOVING_CONTENT_INTO_PRIVATE_SPACE) @RunWith(BedsteadJUnit4::class) @RequireRunOnPrivateProfile class MoveContentEndToEndTest { @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() private lateinit var context: Context private lateinit var parentContext: Context private lateinit var device: UiDevice private lateinit var primaryUser: UserHandle private lateinit var filesHelper: TestFilesHelper companion object { private const val TAG: String = "MoveContentEndToEndTest" private const val PRIVATE_SPACE_PACKAGE_NAME: String = "com.android.privatespace" private const val CHANNEL_ID: String = "FileTransferProgress" private const val WAIT_TIMEOUT_MILLIS: Long = 5_000L private const val DEFAULT_POLL_INTERVAL_MILLIS: Long = 100L private const val SELECT_BUTTON_LABEL: String = "Select" private val DOWNLOADS_FOLDER = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "") } @Before fun setUp() { context = InstrumentationRegistry.getInstrumentation().targetContext primaryUser = TestApis.users().initial().userHandle() parentContext = context.createPackageContextAsUser("android", 0, primaryUser) device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) filesHelper = TestFilesHelper(parentContext, primaryUser) disableAnimationsOnDevice() } @After fun tearDown() { filesHelper.cleanup() } @Test @NotificationsTest fun testTransferOneFile_copyOperation() { val filesToTransfer = filesHelper.create(count = 1) val testFile = filesToTransfer.first() performFileTransfer(filesToTransfer, TransferOperation.COPY) verifyFileExistsInPrivateSpace( testFile.name ) // Tests run in the context of the private space user assertThat(filesHelper.originalFileExists(testFile.uri)).isTrue() } @Test @NotificationsTest fun testTransferOneFile_moveOperation() { val filesToTransfer = filesHelper.create(count = 1) val testFile = filesToTransfer.first() performFileTransfer(filesToTransfer, TransferOperation.MOVE) verifyFileExistsInPrivateSpace( testFile.name ) // Tests run in the context of the private space user assertThat(filesHelper.originalFileExists(testFile.uri)).isFalse() } @Test @NotificationsTest fun testTransferMultipleFiles_copyOperation() { val filesToTransfer = filesHelper.create(count = 5) performFileTransfer(filesToTransfer, TransferOperation.COPY) filesToTransfer.forEach { fileInfo -> verifyFileExistsInPrivateSpace(fileInfo.name) assertThat(filesHelper.originalFileExists(fileInfo.uri)).isTrue() } } @Test @NotificationsTest fun testTransferMultipleFiles_moveOperation() { val filesToTransfer = filesHelper.create(count = 5) performFileTransfer(filesToTransfer, TransferOperation.MOVE) filesToTransfer.forEach { fileInfo -> verifyFileExistsInPrivateSpace(fileInfo.name) assertThat(filesHelper.originalFileExists(fileInfo.uri)).isFalse() } } @Test fun testClickingOnCancel_dialogDismissed() { val filesToTransfer = filesHelper.create(count = 1) val testFile = filesToTransfer.first() performFileTransfer(filesToTransfer, TransferOperation.CANCEL) verifyFileDoesNotExistInPrivateSpace(testFile.name) assertThat(filesHelper.originalFileExists(testFile.uri)).isTrue() } /** A generic method to perform a file transfer operation and assert the notification. */ private fun performFileTransfer(files: List, operation: TransferOperation) { startActivityAndSelectFiles(files) val buttonLabel = when (operation) { TransferOperation.COPY -> context.resources.getString( com.android.privatespace.R.string.move_files_dialog_button_label_copy ) TransferOperation.MOVE -> context.resources.getString( com.android.privatespace.R.string.move_files_dialog_button_label_move ) TransferOperation.CANCEL -> context.resources.getString( com.android.privatespace.R.string.move_files_dialog_button_label_cancel ) } if (operation == TransferOperation.CANCEL) { waitForVisibilityAndClick(buttonLabel) } else { TestApis.notifications().createListener().use { notifications -> waitForVisibilityAndClick(buttonLabel) assertNotificationPosted(notifications) } } } /** Starts the Private Space activity and selects all files in the provided list. */ private fun startActivityAndSelectFiles(files: List) { assertThat(files).isNotEmpty() context.startActivity( Intent(ACTION_ADD_FILES) .setComponent( ComponentName( PRIVATE_SPACE_PACKAGE_NAME, PrivateSpaceActivity::class.qualifiedName.toString(), ) ) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ) // If more than one file, long click the first, then select others and click on the "Select" // button. if (files.size > 1) { waitForVisibilityAndLongClick(files.first().name) files.drop(1).forEach { waitForVisibilityAndClick(it.name) } waitForVisibilityAndClick(SELECT_BUTTON_LABEL) } else { waitForVisibilityAndClick(files.first().name) } } private fun waitForVisibilityAndClick(label: String) { val uiObject = device.wait(Until.findObject(By.text(label)), WAIT_TIMEOUT_MILLIS) assertWithMessage("UI object with text '$label' not found").that(uiObject).isNotNull() uiObject.click() } private fun waitForVisibilityAndLongClick(label: String) { val uiObject = device.wait(Until.findObject(By.text(label)), WAIT_TIMEOUT_MILLIS) assertWithMessage("UI object with text '$label' not found").that(uiObject).isNotNull() uiObject.longClick() } private fun assertNotificationPosted(notifications: NotificationListener) { NotificationListenerQuerySubject.assertThat( notifications .query() .wherePackageName() .isEqualTo(PRIVATE_SPACE_PACKAGE_NAME) .whereNotification() .channelId() .isEqualTo(CHANNEL_ID) ) .wasPosted() } private fun verifyFileExistsInPrivateSpace(expectedFileName: String) { val destinationFile = File(DOWNLOADS_FOLDER, expectedFileName) runBlocking { assertThat(waitForCondition({ destinationFile.exists() })).isTrue() } } private fun verifyFileDoesNotExistInPrivateSpace(fileName: String) { val destinationFile = File(DOWNLOADS_FOLDER, fileName) val condition = { !destinationFile.exists() } runBlocking { assertWithMessage("File '$fileName' should NOT exist in private space downloads") .that(waitForCondition(condition)) .isTrue() } } private suspend fun waitForCondition( condition: () -> Boolean, timeout: Long = WAIT_TIMEOUT_MILLIS, pollInterval: Long = DEFAULT_POLL_INTERVAL_MILLIS, ): Boolean { val endTime = System.currentTimeMillis() + timeout while (System.currentTimeMillis() < endTime) { if (condition()) return true delay(timeMillis = pollInterval) } return false } private fun disableAnimationsOnDevice() { try { ShellCommand.builder("settings put global window_animation_scale 0").execute() ShellCommand.builder("settings put global transition_animation_scale 0").execute() ShellCommand.builder("settings put global animator_duration_scale 0").execute() } catch (e: AdbException) { Log.e(TAG, "disableAnimations: unable to disable animations", e) } } private enum class TransferOperation { COPY, MOVE, CANCEL, } }