/* * 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.os.SystemClock import android.provider.DocumentsContract import android.provider.MediaStore import android.util.Log 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, context: Context, ) : IFileTransferManager { private val contentResolver = context.contentResolver companion object { private const val TAG: String = "FileTransferImpl" 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, ) { val numberOfFiles: Int = uris.size var progress: Int = 0 var copiedBytes: Long = 0L val totalBytes = try { calculateTotalSize(uris) } catch (e: FileNotFoundException) { // TODO(b/394024024) Notify user that the transfer could not be completed Log.e(TAG, "transferFiles: Unable to get the total size of the files. ", e) return } // TODO(b/394024024) Files size and available storage checks // Copy/Move each individual file for (sourceUri in uris) { val metadata = getFileMetadata(sourceUri) if (metadata == null) { // TODO(b/401000421): Deliver a notification to the user about this failed file Log.e(TAG, "Unable to get metadata for uri: $sourceUri") continue } val displayName = metadata.displayName val mimeType = metadata.mimeType val newUri = createNewMediaEntry(displayName, mimeType, destinationPath) if (newUri == null) { // TODO(b/401000421): Deliver a notification to the user about this failed file Log.e(TAG, "Failed to create new media entry for: $displayName") continue } // TODO(b/403206691): We should probably do this operation in parallel copiedBytes += copySingleFile(sourceUri, newUri, totalBytes) { newProgress -> if (newProgress - progress >= 1) { progress = newProgress notificationsHelper.updateProgressNotification( progress, numberOfFiles, keepOriginal, ) } } removeFileIfRequired(sourceUri, keepOriginal) } notificationsHelper.displayCompletionNotification(numberOfFiles, 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, ) contentResolver.query(uri, projection, null, null)?.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: IllegalArgumentException) { Log.e(TAG, "getFileMetadata: Could not retrieve the file name ", e) } } } ?: Log.e(TAG, "Failed to query URI: $uri") 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 suspend fun copySingleFile( sourceUri: Uri, destinationUri: Uri, totalBytes: Long, onProgressUpdate: (Int) -> Unit, ): Long { var copiedBytes = 0L try { contentResolver.openInputStream(sourceUri)?.use { inputStream -> contentResolver.openOutputStream(destinationUri)?.use { outputStream -> val buffer = ByteArray(BUFFER_SIZE) var length: Int var lastUpdated = SystemClock.elapsedRealtime() while (inputStream.read(buffer).also { length = it } > 0) { outputStream.write(buffer, 0, length) copiedBytes += length val newProgress = if (totalBytes == 0L) { 100 } else { ((copiedBytes * 100) / totalBytes).toInt() } val currentTime = SystemClock.elapsedRealtime() if (currentTime - lastUpdated > PROGRESS_NOTIFICATION_UPDATE_INTERVAL_MS) { onProgressUpdate(newProgress) lastUpdated = currentTime } } } ?: { // TODO(b/401000421): Maybe deliver a notification to the user about this // failed file Log.e(TAG, "Failed to open output stream for URI: $destinationUri") } } ?: { // TODO(b/401000421): Maybe deliver a notification to the user about this failed // file Log.e(TAG, "Failed to open input stream for URI: $sourceUri") } } catch (e: IOException) { // TODO(b/401000421): Maybe deliver a notification to the user about this failed file Log.e(TAG, "Error copying file: ${e.message}") } return copiedBytes } @Throws(FileNotFoundException::class) private fun calculateTotalSize(uris: List): Long { var totalBytes: Long = 0 for (fileUri in uris) { totalBytes += contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0L } return totalBytes } private fun removeFileIfRequired(sourceUri: Uri, keepOriginal: Boolean) { if (!keepOriginal) { try { DocumentsContract.deleteDocument(contentResolver, sourceUri) } catch (e: FileNotFoundException) { // TODO(b/394024024) Handle this gracefully Log.e(TAG, "Unable to remove the original file: ${e.message}") } } } }