/* * 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 import android.app.Activity import android.content.ClipData import android.content.ClipDescription import android.content.ContentResolver import android.content.Context import android.content.Intent import android.multiuser.Flags import android.net.Uri import android.os.Environment import android.os.statsd.privatespace.TransferErrorCode import android.os.statsd.privatespace.TransferResult import android.platform.test.annotations.RequiresFlagsDisabled import android.platform.test.annotations.RequiresFlagsEnabled import android.platform.test.flag.junit.CheckFlagsRule import android.platform.test.flag.junit.DeviceFlagsValueProvider import android.privatespace.TestUtils.Companion.getTestUris import android.util.StatsEvent import android.util.StatsEventTestUtils import android.util.StatsLog import androidx.activity.result.ActivityResult import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.dx.mockito.inline.extended.ExtendedMockito import com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession import com.android.privatespace.filetransfer.FileTransferService import com.android.privatespace.filetransfer.IFileTransferStateRepository import com.android.privatespace.filetransfer.TransferState import com.google.common.truth.Truth.assertThat import com.google.protobuf.ExtensionRegistryLite import kotlin.test.assertEquals import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestWatcher import org.junit.runner.Description import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.MockitoSession import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.spy import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @OptIn(ExperimentalCoroutinesApi::class) @RequiresFlagsEnabled(Flags.FLAG_ENABLE_MOVING_CONTENT_INTO_PRIVATE_SPACE) @RunWith(AndroidJUnit4::class) class PrivateSpaceViewModelTest { @get:Rule val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule() @get:Rule val mainDispatcherRule = MainDispatcherRule() private lateinit var privateSpaceViewModel: PrivateSpaceViewModel @Mock private lateinit var mockContext: Context @Mock private lateinit var mockContentResolver: ContentResolver @Mock private lateinit var mockStorageHelper: StorageHelper @Mock private lateinit var mockFileTransferStateRepository: IFileTransferStateRepository private val statsEventCaptor = argumentCaptor() private val registry = ExtensionRegistryLite.newInstance() private lateinit var mockitoSession: MockitoSession private val testUri1 = Uri.parse("content://10@authority/item/1") @Before fun setUp() = runBlocking { mockitoSession = mockitoSession() .initMocks(this@PrivateSpaceViewModelTest) .strictness(Strictness.LENIENT) .mockStatic(StatsLog::class.java) .startMocking() whenever(mockStorageHelper.getAvailableDeviceStorage()).thenReturn(Long.MAX_VALUE) whenever(mockStorageHelper.calculateTotalSize(any(), any(), any())).thenReturn(1) whenever(mockContext.contentResolver).thenReturn(mockContentResolver) registry.add(PrivatespaceExtensionAtoms.privateSpaceMoveContentEvent) privateSpaceViewModel = PrivateSpaceViewModel( storageHelper = mockStorageHelper, fileTransferStateRepository = mockFileTransferStateRepository, ) } @After fun tearDown() { mockitoSession.finishMocking() } @Test fun initialUiState() { assertThat(privateSpaceViewModel.uiState).isEqualTo(PrivateSpaceUiState.STARTED) } @Test fun validateSelectedFileLimits_moreThanAvailableDeviceStorage() = runTest { val mockAvailableDeviceStorage = 500_000_000L val mockTotalFilesSizeToBeTransferred = 1_500_000_000L // More than the mockAvailableDeviceStorage value whenever(mockStorageHelper.calculateTotalSize(any(), any(), any())) .thenReturn(mockTotalFilesSizeToBeTransferred) whenever(mockStorageHelper.getAvailableDeviceStorage()) .thenReturn(mockAvailableDeviceStorage) assertThat( privateSpaceViewModel.validateSelectedFileLimits(getTestUris(), mockContentResolver) ) .isFalse() assertThat(privateSpaceViewModel.uiState) .isEqualTo(PrivateSpaceUiState.SHOW_NOT_ENOUGH_SPACE_DIALOG) } @Test fun validateSelectedFileLimits_moreThanTotalFileSizeLimits() = runTest { val aboveTotalLimitMockValue = 3_000_000_000L // Limit size is 2GB whenever(mockStorageHelper.calculateTotalSize(any(), any(), any())) .thenReturn(aboveTotalLimitMockValue) assertThat( privateSpaceViewModel.validateSelectedFileLimits(getTestUris(), mockContentResolver) ) .isFalse() assertThat(privateSpaceViewModel.uiState) .isEqualTo(PrivateSpaceUiState.SHOW_ABOVE_FILE_SIZE_LIMITS_DIALOG) } @Test fun validateSelectedFileLimits_moreThanFilesCountLimit() = runTest { val selectedUris: List = (1..101).map { i -> Uri.parse("content://authority/item/$i") } assertThat( privateSpaceViewModel.validateSelectedFileLimits(selectedUris, mockContentResolver) ) .isFalse() assertThat(privateSpaceViewModel.uiState) .isEqualTo(PrivateSpaceUiState.SHOW_TOO_MANY_FILES_SELECTED_DIALOG) } @Test fun validateSelectedFileLimits_moreThanFilesCountLimit_noCallsToStorageHelper() = runTest { val selectedUris: List = (1..101).map { i -> Uri.parse("content://authority/item/$i") } privateSpaceViewModel.validateSelectedFileLimits(selectedUris, mockContentResolver) verifyNoInteractions(mockStorageHelper) } @Test fun validateSelectedFileLimits_moreThanAvailableDeviceStorage_logsEvent() = runTest { val mockAvailableDeviceStorage = 500_000_000L val mockTotalFilesSizeToBeTransferred = 1_500_000_000L // More than the mockAvailableDeviceStorage value whenever(mockStorageHelper.calculateTotalSize(any(), any(), any())) .thenReturn(mockTotalFilesSizeToBeTransferred) whenever(mockStorageHelper.getAvailableDeviceStorage()) .thenReturn(mockAvailableDeviceStorage) assertThat( privateSpaceViewModel.validateSelectedFileLimits(getTestUris(), mockContentResolver) ) .isFalse() val event = getPrivateSpaceMoveContentStatsEvent() assertThat(event.transferResult).isEqualTo(TransferResult.TRANSFER_RESULT_FAILURE) assertThat(event.failedFileErrorCodesList) .isEqualTo(List(2) { TransferErrorCode.TRANSFER_ERROR_ABOVE_AVAILABLE_DEVICE_STORAGE }) } @Test fun validateSelectedFileLimits_moreThanTotalFileSizeLimits_logsEvent() = runTest { val aboveTotalLimitMockValue = PrivateSpaceViewModel.MAXIMUM_TRANSFER_BYTES_ALLOWED * 2 whenever(mockStorageHelper.calculateTotalSize(any(), any(), any())) .thenReturn(aboveTotalLimitMockValue) assertThat( privateSpaceViewModel.validateSelectedFileLimits(getTestUris(), mockContentResolver) ) .isFalse() val event = getPrivateSpaceMoveContentStatsEvent() assertThat(event.transferResult).isEqualTo(TransferResult.TRANSFER_RESULT_FAILURE) assertThat(event.failedFileErrorCodesList) .isEqualTo(List(2) { TransferErrorCode.TRANSFER_ERROR_ABOVE_FILE_SIZE_LIMITS }) } @Test fun validateSelectedFileLimits_moreThanFilesCountLimit_logsEvent() = runTest { val fileCountLimit = PrivateSpaceViewModel.MAXIMUM_NUMBER_OF_FILES_ALLOWED val selectedUris: List = (1..fileCountLimit + 1).map { i -> Uri.parse("content://authority/item/$i") } assertThat( privateSpaceViewModel.validateSelectedFileLimits(selectedUris, mockContentResolver) ) .isFalse() val event = getPrivateSpaceMoveContentStatsEvent() assertThat(event.transferResult).isEqualTo(TransferResult.TRANSFER_RESULT_FAILURE) assertThat(event.failedFileErrorCodesList) .isEqualTo( List(fileCountLimit + 1) { TransferErrorCode.TRANSFER_ERROR_TOO_MANY_FILES_SELECTED } ) } @Test fun validateSelectedFileLimits_multipleUris() = runTest { val selectedUris: List = (1..10).map { i -> Uri.parse("content://authority/item/$i") } assertThat( privateSpaceViewModel.validateSelectedFileLimits(selectedUris, mockContentResolver) ) .isTrue() assertThat(privateSpaceViewModel.uiState).isEqualTo(PrivateSpaceUiState.STARTED) } @Test fun validateSelectedFileLimits() = runTest { assertThat( privateSpaceViewModel.validateSelectedFileLimits( listOf(testUri1), mockContentResolver, ) ) .isTrue() assertThat(privateSpaceViewModel.uiState).isEqualTo(PrivateSpaceUiState.STARTED) } @RequiresFlagsDisabled(Flags.FLAG_PRIVATE_SPACE_FILE_LIMIT_CHECK_TIMEOUT) @Test fun onDocumentSelectionFromDocumentPicker_withFlagDisabled_validationSucceeds_showsMoveFilesDialog() = runTest { val mockIntent = createIntentWithClipData(listOf(testUri1)) val activityResult = ActivityResult(Activity.RESULT_OK, mockIntent) whenever(mockStorageHelper.calculateTotalSize(any(), any(), any())).thenReturn(100L) whenever(mockStorageHelper.getAvailableDeviceStorage()).thenReturn(Long.MAX_VALUE) privateSpaceViewModel.onDocumentSelectionFromDocumentPicker( activityResult, mockContentResolver, ) assertThat(privateSpaceViewModel.uiState) .isEqualTo(PrivateSpaceUiState.CHECKING_PROGRESS_INDICATOR) advanceUntilIdle() assertThat(privateSpaceViewModel.uiState) .isEqualTo(PrivateSpaceUiState.SHOW_MOVE_FILES_DIALOG) } @RequiresFlagsEnabled(Flags.FLAG_PRIVATE_SPACE_FILE_LIMIT_CHECK_TIMEOUT) @Test fun onDocumentSelectionFromDocumentPicker_withFlagEnabled_validationSucceedsWithinTime_showsMoveFilesDialog() = runTest { val viewModelSpy = spy(privateSpaceViewModel) doAnswer { runBlocking { delay(PrivateSpaceViewModel.FILE_LIMIT_CHECK_TIMEOUT_MS - 100) } true } .whenever(viewModelSpy) .validateSelectedFileLimits(any(), any()) val mockIntent = createIntentWithClipData(listOf(testUri1)) val activityResult = ActivityResult(Activity.RESULT_OK, mockIntent) viewModelSpy.onDocumentSelectionFromDocumentPicker(activityResult, mockContentResolver) assertThat(viewModelSpy.uiState) .isEqualTo(PrivateSpaceUiState.CHECKING_PROGRESS_INDICATOR) advanceUntilIdle() assertThat(viewModelSpy.uiState).isEqualTo(PrivateSpaceUiState.SHOW_MOVE_FILES_DIALOG) } @Test fun onDocumentSelectionFromDocumentPicker_withFlagEnabled_validationFailsWithinTime_skipMoveFilesDialog() = runTest { val viewModelSpy = spy(privateSpaceViewModel) doAnswer { runBlocking { delay(PrivateSpaceViewModel.FILE_LIMIT_CHECK_TIMEOUT_MS - 100) } false } .whenever(viewModelSpy) .validateSelectedFileLimits(any(), any()) val mockIntent = createIntentWithClipData(listOf(testUri1)) val activityResult = ActivityResult(Activity.RESULT_OK, mockIntent) viewModelSpy.onDocumentSelectionFromDocumentPicker(activityResult, mockContentResolver) assertThat(viewModelSpy.uiState) .isEqualTo(PrivateSpaceUiState.CHECKING_PROGRESS_INDICATOR) advanceUntilIdle() assertThat(viewModelSpy.uiState) .isNotEqualTo(PrivateSpaceUiState.SHOW_MOVE_FILES_DIALOG) } @RequiresFlagsEnabled(Flags.FLAG_PRIVATE_SPACE_FILE_LIMIT_CHECK_TIMEOUT) @Test fun onDocumentSelectionFromDocumentPicker_withFlagEnabled_whenFileLimitCheckTimesOut_showsMoveFilesDialog() = runTest { val viewModelSpy = spy(privateSpaceViewModel) doAnswer { runBlocking { delay(PrivateSpaceViewModel.FILE_LIMIT_CHECK_TIMEOUT_MS + 10000) } // The return value here doesn't matter, as the timeout will occur before this // function completes. true } .whenever(viewModelSpy) .validateSelectedFileLimits(any(), any()) val mockIntent = createIntentWithClipData(listOf(testUri1)) val activityResult = ActivityResult(Activity.RESULT_OK, mockIntent) viewModelSpy.onDocumentSelectionFromDocumentPicker(activityResult, mockContentResolver) assertThat(viewModelSpy.uiState) .isEqualTo(PrivateSpaceUiState.CHECKING_PROGRESS_INDICATOR) advanceUntilIdle() assertThat(viewModelSpy.uiState).isEqualTo(PrivateSpaceUiState.SHOW_MOVE_FILES_DIALOG) } @Test fun uriFromDifferentUser_hasUserInfo() { val uriString = "content://10@authority/item/1" val testUri = Uri.parse(uriString) assertEquals(privateSpaceViewModel.isUriFromSameUser(testUri), false) } @Test fun uriFromSameUser_hasNullUserInfo() { val uriString = "content://authority/item/2" val testUri = Uri.parse(uriString) assertEquals(privateSpaceViewModel.isUriFromSameUser(testUri), true) } @Test fun canStartNewTransfer_serviceRunning() = runTest { whenever(mockFileTransferStateRepository.getTransferState(mockContext)) .thenReturn(TransferState(keepOriginal = true, transferInProgress = true)) privateSpaceViewModel.checkIfNewTransferCanStart(mockContext) assertThat(privateSpaceViewModel.uiState) .isEqualTo(PrivateSpaceUiState.CHECKING_PROGRESS_INDICATOR) advanceUntilIdle() assertThat(privateSpaceViewModel.uiState) .isEqualTo(PrivateSpaceUiState.SHOW_TRANSFER_IN_PROGRESS_DIALOG) assertThat(privateSpaceViewModel.isPreviousTransferCopy).isTrue() } @Test fun canStartNewTransfer_serviceRunning_logsEvent() = runTest { whenever(mockFileTransferStateRepository.getTransferState(mockContext)) .thenReturn(TransferState(keepOriginal = true, transferInProgress = true)) privateSpaceViewModel.checkIfNewTransferCanStart(mockContext) advanceUntilIdle() val event = getPrivateSpaceMoveContentStatsEvent() assertThat(event.transferResult).isEqualTo(TransferResult.TRANSFER_RESULT_FAILURE) assertThat(event.failedFileErrorCodesList) .containsExactly(TransferErrorCode.TRANSFER_ERROR_ANOTHER_TRANSFER_IN_PROGRESS) } @Test fun canStartNewTransfer() = runTest { whenever(mockFileTransferStateRepository.getTransferState(mockContext)) .thenReturn(TransferState(keepOriginal = true, transferInProgress = false)) privateSpaceViewModel.checkIfNewTransferCanStart(mockContext) assertThat(privateSpaceViewModel.uiState) .isEqualTo(PrivateSpaceUiState.CHECKING_PROGRESS_INDICATOR) advanceUntilIdle() assertThat(privateSpaceViewModel.uiState) .isEqualTo(PrivateSpaceUiState.SHOW_DOCUMENT_PICKER) } @Test fun showMoveFilesDialog() { val uriList = arrayListOf(testUri1) privateSpaceViewModel.showMoveFilesDialog(uriList) assertThat(privateSpaceViewModel.fileUris).isEqualTo(uriList) assertThat(privateSpaceViewModel.uiState) .isEqualTo(PrivateSpaceUiState.SHOW_MOVE_FILES_DIALOG) } @Test fun showMoveFilesDialog_emptyUriList() { privateSpaceViewModel.showMoveFilesDialog(arrayListOf()) assertThat(privateSpaceViewModel.uiState).isEqualTo(PrivateSpaceUiState.FINISHED) } @Test fun finishFlow() { privateSpaceViewModel.finishFlow() assertThat(privateSpaceViewModel.uiState).isEqualTo(PrivateSpaceUiState.FINISHED) } @Test fun moveFiles_singleUri() { privateSpaceViewModel.fileUris = arrayListOf(testUri1) val intentCaptor = argumentCaptor() privateSpaceViewModel.moveFiles(mockContext) verify(mockContext).startForegroundService(intentCaptor.capture()) assertServiceIntent( intent = intentCaptor.firstValue, keepOriginal = false, uris = arrayListOf(testUri1), ) assertThat(privateSpaceViewModel.uiState).isEqualTo(PrivateSpaceUiState.FINISHED) verify(mockContentResolver) .takePersistableUriPermission( testUri1, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) } @Test fun moveFiles_multipleUris() { val uris: ArrayList = (1..10).mapTo(ArrayList()) { i -> Uri.parse("content://authority/item/$i") } privateSpaceViewModel.fileUris = uris val intentCaptor = argumentCaptor() privateSpaceViewModel.moveFiles(mockContext) verify(mockContext).startForegroundService(intentCaptor.capture()) assertServiceIntent(intent = intentCaptor.firstValue, keepOriginal = false, uris = uris) assertThat(privateSpaceViewModel.uiState).isEqualTo(PrivateSpaceUiState.FINISHED) uris.forEach { uri -> verify(mockContentResolver) .takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) } } @Test fun copyFiles_singleUri() { privateSpaceViewModel.fileUris = arrayListOf(testUri1) val intentCaptor = argumentCaptor() privateSpaceViewModel.copyFiles(mockContext) verify(mockContext).startForegroundService(intentCaptor.capture()) assertServiceIntent( intent = intentCaptor.firstValue, keepOriginal = true, uris = arrayListOf(testUri1), ) assertThat(privateSpaceViewModel.uiState).isEqualTo(PrivateSpaceUiState.FINISHED) verify(mockContentResolver) .takePersistableUriPermission( testUri1, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) } @Test fun copyFiles_multipleUris() { val uris: ArrayList = (1..10).mapTo(ArrayList()) { i -> Uri.parse("content://authority/item/$i") } privateSpaceViewModel.fileUris = uris val intentCaptor = argumentCaptor() privateSpaceViewModel.copyFiles(mockContext) verify(mockContext).startForegroundService(intentCaptor.capture()) assertServiceIntent(intent = intentCaptor.firstValue, keepOriginal = true, uris = uris) assertThat(privateSpaceViewModel.uiState).isEqualTo(PrivateSpaceUiState.FINISHED) uris.forEach { uri -> verify(mockContentResolver) .takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) } } private fun assertServiceIntent(intent: Intent, keepOriginal: Boolean, uris: ArrayList) { assertThat(intent.component).isNotNull() assertThat(intent.component!!.className).isEqualTo(FileTransferService::class.java.name) assertThat( intent.getParcelableArrayListExtra( FileTransferService.SOURCE_URIS_EXTRA, Uri::class.java, ) ) .isEqualTo(uris) assertThat(intent.getStringExtra(FileTransferService.DESTINATION_PATH_EXTRA)) .isEqualTo(Environment.DIRECTORY_DOWNLOADS) assertThat(intent.getBooleanExtra(FileTransferService.KEEP_ORIGINAL_EXTRA, !keepOriginal)) .isEqualTo(keepOriginal) } private fun getPrivateSpaceMoveContentStatsEvent(): PrivateSpaceMoveContentEvent { ExtendedMockito.verify { StatsLog.write(statsEventCaptor.capture()) } val atom = StatsEventTestUtils.convertToAtom(statsEventCaptor.firstValue, registry) assertThat(atom.hasExtension(PrivatespaceExtensionAtoms.privateSpaceMoveContentEvent)) .isTrue() val event = atom.getExtension(PrivatespaceExtensionAtoms.privateSpaceMoveContentEvent) return event } private fun createIntentWithClipData(uris: List): Intent { require(uris.isNotEmpty()) { "URIs list cannot be empty for creating ClipData" } val clipData = ClipData( ClipDescription("test", arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN)), ClipData.Item(uris.first()), ) uris.drop(1).forEach { uri -> clipData.addItem(ClipData.Item(uri)) } val intent = Intent() intent.setData(null) intent.setClipData(clipData) return intent } @ExperimentalCoroutinesApi class MainDispatcherRule(val testDispatcher: TestDispatcher = StandardTestDispatcher()) : TestWatcher() { override fun starting(description: Description) { Dispatchers.setMain(testDispatcher) } override fun finished(description: Description) { Dispatchers.resetMain() } } }