/* * 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.filetransfer import android.content.ContentValues import android.content.Context import android.net.Uri import android.provider.DocumentsContract import android.provider.MediaStore import android.util.Log import com.android.privatespace.PrivateSpaceStatsLog import com.android.privatespace.StorageHelper import com.android.privatespace.StorageHelperImpl import com.android.privatespace.logging.FileTransferResultLogger import com.android.privatespace.logging.FilesLoggingDetails import java.io.FileNotFoundException import java.io.IOException /** * An object responsible for transferring selected files to the current and removing the original * files if {@link FileTransferService.KEEP_ORIGINAL_EXTRA} is set to false. This class is designed * to run within the private profile user, ensuring that transferred files are stored inside the * private space. */ class FileTransferManagerImpl( private var notificationsHelper: NotificationsHelper, private var context: Context, private var fileTransferStateRepository: IFileTransferStateRepository, private var storageHelper: StorageHelper = StorageHelperImpl, private var filesLoggingDetails: FilesLoggingDetails = FilesLoggingDetails(), private var clock: SystemClockWrapper = SystemClockWrapper(), ) : IFileTransferManager { private val contentResolver = context.contentResolver private var lastUpdatedTime: Long = 0L private var totalCopiedBytes: Long = 0L companion object { private const val TAG: String = "FileTransferManagerImpl" private const val DEFAULT_MIMETYPE = "application/octet-stream" private const val PROGRESS_NOTIFICATION_UPDATE_INTERVAL_MS: Long = 1000L private const val BUFFER_SIZE = 1024 } override suspend fun transferFiles( uris: List, keepOriginal: Boolean, destinationPath: String, ) { updateTransferState(keepOriginal, transferInProgress = true) val numberOfFiles: Int = uris.size var progress = 0 val totalBytes = storageHelper.calculateTotalSize( contentResolver, uris, { uri, e -> Log.e( TAG, "calculateTotalSize: Unable to get the file size for the uri $uri", e, ) }, ) lastUpdatedTime = clock.elapsedRealtime() val availableDeviceStorage = storageHelper.getAvailableDeviceStorage() if (totalBytes > availableDeviceStorage) { FileTransferResultLogger.logTransferFailedOnPrechecks( errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_ABOVE_AVAILABLE_DEVICE_STORAGE, numberOfFiles = uris.size, totalSizeFilesSelected = totalBytes, ) notificationsHelper.postNotEnoughStorageNotification(keepOriginal) Log.e( TAG, "Total files size is above the $availableDeviceStorage available device storage", ) return } // Copy/Move each individual file for (sourceUri in uris) { val metadata = getFileMetadata(sourceUri) if (metadata == null) { Log.e(TAG, "Unable to get metadata for uri: $sourceUri") // error handled inside of the getFileMetadata method continue } val displayName = metadata.displayName val mimeType = metadata.mimeType val newUri = createNewMediaEntry(displayName, mimeType, destinationPath) if (newUri == null) { Log.e(TAG, "Failed to create new media entry for: $displayName") filesLoggingDetails.addFailedFileDetails( errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_CREATE_NEW_MEDIA_ENTRY, sizeBytes = 0, mimeType = mimeType, ) continue } // TODO(b/403206691): We should probably do this operation in parallel val copySuccess = copySingleFile(sourceUri, newUri, totalBytes, mimeType) { newProgress -> if (newProgress - progress >= 1) { progress = newProgress notificationsHelper.updateProgressNotification( progress, numberOfFiles, keepOriginal, ) } } if (copySuccess && removeFileIfRequired(sourceUri, keepOriginal, mimeType)) { filesLoggingDetails.addSuccessFileDetails( // TODO(b/400696164): refactor StorageHelper to support size-per-file info sizeBytes = 0, mimeType = mimeType, ) } } FileTransferResultLogger.logMoveContentResultEvent( keepOriginal = keepOriginal, filesSelectedCount = uris.size, filesLoggingDetails = filesLoggingDetails, ) } /** * Display the end of transfer notification. If there are some files that failed to transfer, a * partial transfer notification will be displayed. Otherwise a successful transfer completion * notification will be shown. */ override fun postEndOfTransferNotification(numberOfFiles: Int, keepOriginal: Boolean) { if (filesLoggingDetails.failedFileDetails.isEmpty()) { notificationsHelper.displaySuccessfulCompletionNotification(numberOfFiles, keepOriginal) } else { notificationsHelper.displayPartialTransferErrorNotification(keepOriginal) } } /** Retrieves file metadata such as display name and MIME type. */ private fun getFileMetadata(uri: Uri): FileMetadata? { val projection = arrayOf( DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_DOCUMENT_ID, ) val cursor = contentResolver.query(uri, projection, null, null) if (cursor == null) { Log.e(TAG, "Failed to query URI: $uri") filesLoggingDetails.addFailedFileDetails( errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_QUERY_URI, sizeBytes = 0, ) return null } cursor.use { cursor -> if (cursor.moveToFirst()) { try { val displayName = cursor.getString( cursor.getColumnIndexOrThrow( DocumentsContract.Document.COLUMN_DISPLAY_NAME ) ) val mimeType: String = contentResolver.getType(uri) ?: DEFAULT_MIMETYPE return FileMetadata(displayName, mimeType) } catch (e: Exception) { when (e) { is IllegalArgumentException, is SecurityException, is NullPointerException -> { Log.e(TAG, "getFileMetadata: Could not retrieve the file name ", e) filesLoggingDetails.addFailedFileDetails( errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_GET_FILE_NAME, sizeBytes = 0, ) return null } else -> throw e } } } } // cursor was empty filesLoggingDetails.addFailedFileDetails( errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_GET_METADATA, sizeBytes = 0, ) return null } /** Creates a new media entry in MediaStore. */ private fun createNewMediaEntry( displayName: String, mimeType: String, destinationPath: String, ): Uri? { return contentResolver.insert( MediaStore.Downloads.EXTERNAL_CONTENT_URI, ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) put(MediaStore.MediaColumns.MIME_TYPE, mimeType) put(MediaStore.MediaColumns.RELATIVE_PATH, destinationPath) }, ) } /** * Copies a file from sourceUri to destinationUri and tracks progress. * * @return total bytes of this file copied */ private fun copySingleFile( sourceUri: Uri, destinationUri: Uri, totalBytes: Long, mimeType: String, onProgressUpdate: (Int) -> Unit, ): Boolean { var currentFileCopiedBytes = 0L var copySuccess = true try { val inputStream = contentResolver.openInputStream(sourceUri) if (inputStream == null) { Log.e(TAG, "Failed to open input stream for URI: $sourceUri") filesLoggingDetails.addFailedFileDetails( errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_OPEN_INPUT_STREAM, sizeBytes = 0, mimeType = mimeType, ) return false } inputStream.use { inputStream -> val outputStream = contentResolver.openOutputStream(destinationUri) if (outputStream == null) { Log.e(TAG, "Failed to open output stream for URI: $destinationUri") filesLoggingDetails.addFailedFileDetails( errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_OPEN_OUTPUT_STREAM, sizeBytes = 0, mimeType = mimeType, ) return false } outputStream.use { outputStream -> val buffer = ByteArray(BUFFER_SIZE) var length: Int while (inputStream.read(buffer).also { length = it } > 0) { outputStream.write(buffer, 0, length) currentFileCopiedBytes += length val newProgress = if (totalBytes == 0L) { 100 } else { (((totalCopiedBytes + currentFileCopiedBytes) * 100) / totalBytes) .toInt() } val currentTime = clock.elapsedRealtime() if ( currentTime - lastUpdatedTime > PROGRESS_NOTIFICATION_UPDATE_INTERVAL_MS ) { onProgressUpdate(newProgress) lastUpdatedTime = currentTime } } } } } catch (e: IOException) { Log.e(TAG, "Error copying file: ${e.message}") filesLoggingDetails.addFailedFileDetails( errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_COPY_FILE, sizeBytes = 0, mimeType = mimeType, ) copySuccess = false } totalCopiedBytes += currentFileCopiedBytes return copySuccess } private fun removeFileIfRequired( sourceUri: Uri, keepOriginal: Boolean, mimeType: String, ): Boolean { if (!keepOriginal) { try { DocumentsContract.deleteDocument(contentResolver, sourceUri) } catch (e: FileNotFoundException) { Log.e(TAG, "Unable to remove the original file:", e) filesLoggingDetails.addFailedFileDetails( errorCode = PrivateSpaceStatsLog .PRIVATE_SPACE_MOVE_CONTENT_EVENT__FAILED_FILE_ERROR_CODES__TRANSFER_ERROR_REMOVE_ORIGINAL_FILE, sizeBytes = 0, mimeType = mimeType, ) return false } } return true } private suspend fun updateTransferState(keepOriginal: Boolean, transferInProgress: Boolean) { fileTransferStateRepository.saveState(context, keepOriginal, transferInProgress) } }