/* * 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.RESULT_OK 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.util.Log import androidx.activity.result.ActivityResult import androidx.annotation.OpenForTesting import androidx.annotation.VisibleForTesting import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.privatespace.filetransfer.FileTransferService import com.android.privatespace.filetransfer.FileTransferService.Companion.DESTINATION_PATH_EXTRA import com.android.privatespace.filetransfer.FileTransferService.Companion.KEEP_ORIGINAL_EXTRA import com.android.privatespace.filetransfer.FileTransferService.Companion.SOURCE_URIS_EXTRA import com.android.privatespace.filetransfer.FileTransferStateRepository import com.android.privatespace.filetransfer.IFileTransferStateRepository import com.android.privatespace.logging.FileTransferResultLogger import java.util.ArrayList import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull /** A ViewModel for the PrivateSpaceActivity move files feature and its dialog. */ @OpenForTesting open class PrivateSpaceViewModel( private val storageHelper: StorageHelper = StorageHelperImpl, private val fileTransferStateRepository: IFileTransferStateRepository = FileTransferStateRepository, ) : ViewModel() { companion object { private const val TAG = "PrivateSpaceViewModel" internal const val MAXIMUM_TRANSFER_BYTES_ALLOWED: Long = 2_000_000_000L internal const val MAXIMUM_NUMBER_OF_FILES_ALLOWED: Int = 100 internal const val TRANSFER_STATUS_TIMEOUT_MS = 3_000L // 3 seconds internal const val FILE_LIMIT_CHECK_TIMEOUT_MS = 3_000L // 3 seconds } open var uiState by mutableStateOf(PrivateSpaceUiState.STARTED) private set var isPreviousTransferCopy: Boolean = true private set private val _uiEventFlow = MutableSharedFlow() val uiEventFlow = _uiEventFlow.asSharedFlow() @VisibleForTesting internal var fileUris: ArrayList = ArrayList() fun showMoveFilesDialog(uris: ArrayList) { fileUris = uris if (uris.isNotEmpty()) { uiState = PrivateSpaceUiState.SHOW_MOVE_FILES_DIALOG } else { // Finish if no URIs are selected. finishFlow() } } fun checkIfNewTransferCanStart(context: Context) { uiState = PrivateSpaceUiState.CHECKING_PROGRESS_INDICATOR viewModelScope.launch { val transferState = withTimeoutOrNull(TRANSFER_STATUS_TIMEOUT_MS) { fileTransferStateRepository.getTransferState(context) } if (transferState != null && transferState.transferInProgress) { Log.e(TAG, "New transfer invoked while another transfer already in progress") uiState = PrivateSpaceUiState.SHOW_TRANSFER_IN_PROGRESS_DIALOG isPreviousTransferCopy = transferState.keepOriginal FileTransferResultLogger.logAnotherTransferInProgressEvent() } else { if (transferState == null) { Log.w( TAG, "getTransferState timed out after $TRANSFER_STATUS_TIMEOUT_MS ms. Falling back to SHOW_DOCUMENT_PICKER.", ) } else { Log.d(TAG, "No transfer in progress, showing document picker.") } uiState = PrivateSpaceUiState.SHOW_DOCUMENT_PICKER } } } internal fun onDocumentSelectionFromDocumentPicker( result: ActivityResult, contentResolver: ContentResolver, ) { uiState = PrivateSpaceUiState.CHECKING_PROGRESS_INDICATOR if (result.resultCode == RESULT_OK) { result.data?.let { validateAndTransferSelectedDocuments(it, contentResolver) } } else { Log.w(TAG, "Document picker activity result not OK: resultCode = ${result.resultCode}") finishFlow() } } private fun validateAndTransferSelectedDocuments( resultIntent: Intent, contentResolver: ContentResolver, ) { val uris = getValidatedUriList(resultIntent) if (Flags.privateSpaceFileLimitCheckTimeout()) { viewModelScope.launch { val passedFileLimitChecks = withTimeoutOrNull(FILE_LIMIT_CHECK_TIMEOUT_MS) { validateSelectedFileLimits(uris, contentResolver) } Log.d( TAG, "File limit checks result: ${passedFileLimitChecks?.let { "passed=$it" } ?: "timed out"}", ) // Proceed if the check timed out or checks have passed if (passedFileLimitChecks == null || passedFileLimitChecks) { showMoveFilesDialog(uris) } } } else { viewModelScope.launch { if (validateSelectedFileLimits(uris, contentResolver)) { showMoveFilesDialog(uris) } } } } private fun getValidatedUriList(resultIntent: Intent): ArrayList { // Single Uri is passed in intent's data, multiple uris in clip data resultIntent.data?.let { if (isUriFromSameUser(it)) { sendCannotTransferFromSameUserEvent() return arrayListOf() } return arrayListOf(it) } resultIntent.clipData?.let { clipData -> val uriList = ArrayList() for (i in 0 until clipData.itemCount) { val uri = clipData.getItemAt(i).uri if (isUriFromSameUser(uri)) { sendCannotTransferFromSameUserEvent() return arrayListOf() } uriList.add(uri) } return uriList } return arrayListOf() } @OpenForTesting internal fun isUriFromSameUser(uri: Uri): Boolean { // DocumentsUI uris contain a user id as part of the authority if the file is from a // different user than the current user. If the user is null, the file is from the current // user. return uri.userInfo == null } private fun sendCannotTransferFromSameUserEvent() { viewModelScope.launch { _uiEventFlow.emit(PrivateSpaceUIEvent.SHOW_CANNOT_TRANSFER_FROM_SAME_USER_TOAST) } } @OpenForTesting open fun isCopyOperationForErrorDialog(): Boolean { return isPreviousTransferCopy } @OpenForTesting open fun moveFiles(context: Context) { val serviceIntent = getServiceIntent(context) serviceIntent.putExtra(KEEP_ORIGINAL_EXTRA, false) persistUriPermissions(context) context.startForegroundService(serviceIntent) finishFlow() } @OpenForTesting open fun copyFiles(context: Context) { val serviceIntent = getServiceIntent(context) serviceIntent.putExtra(KEEP_ORIGINAL_EXTRA, true) persistUriPermissions(context) context.startForegroundService(serviceIntent) finishFlow() } @OpenForTesting open fun finishFlow() { uiState = PrivateSpaceUiState.FINISHED } @OpenForTesting internal suspend fun validateSelectedFileLimits( uris: List, contentResolver: ContentResolver, ): Boolean { val numberOfFiles = uris.size if (numberOfFiles > MAXIMUM_NUMBER_OF_FILES_ALLOWED) { uiState = PrivateSpaceUiState.SHOW_TOO_MANY_FILES_SELECTED_DIALOG Log.w( TAG, "$numberOfFiles files selected which is above $MAXIMUM_NUMBER_OF_FILES_ALLOWED allowed number of files to transfer at once.", ) FileTransferResultLogger.logTransferFailedOnPrechecks( numberOfFiles = numberOfFiles, errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_TOO_MANY_FILES_SELECTED, totalSizeFilesSelected = 0, ) return false } val totalBytes = storageHelper.calculateTotalSize( contentResolver, uris, { uri, e -> Log.e( TAG, "calculateTotalSize: Unable to get the file size for the uri $uri", e, ) }, ) val availableDeviceStorage = storageHelper.getAvailableDeviceStorage() if (totalBytes > MAXIMUM_TRANSFER_BYTES_ALLOWED) { uiState = PrivateSpaceUiState.SHOW_ABOVE_FILE_SIZE_LIMITS_DIALOG Log.w( TAG, "Total files size is above the $MAXIMUM_TRANSFER_BYTES_ALLOWED maximum transfer bytes", ) FileTransferResultLogger.logTransferFailedOnPrechecks( numberOfFiles = numberOfFiles, errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_ABOVE_FILE_SIZE_LIMITS, totalSizeFilesSelected = totalBytes, ) return false } if (totalBytes > availableDeviceStorage) { uiState = PrivateSpaceUiState.SHOW_NOT_ENOUGH_SPACE_DIALOG Log.w( TAG, "Total files size is above the $availableDeviceStorage available device storage", ) FileTransferResultLogger.logTransferFailedOnPrechecks( numberOfFiles = numberOfFiles, errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_ABOVE_AVAILABLE_DEVICE_STORAGE, totalSizeFilesSelected = totalBytes, ) return false } return true } private fun getServiceIntent(context: Context): Intent { val intent = Intent(context, FileTransferService::class.java) intent.putParcelableArrayListExtra(SOURCE_URIS_EXTRA, fileUris) intent.putExtra(DESTINATION_PATH_EXTRA, Environment.DIRECTORY_DOWNLOADS) return intent } private fun persistUriPermissions(context: Context) { fileUris.forEach { uri -> context.contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) } } }