1 /* <lambda>null2 * Copyright (C) 2025 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.privatespace.filetransfer 18 19 import android.content.ContentValues 20 import android.content.Context 21 import android.net.Uri 22 import android.os.SystemClock 23 import android.provider.DocumentsContract 24 import android.provider.MediaStore 25 import android.util.Log 26 import java.io.FileNotFoundException 27 import java.io.IOException 28 29 /** 30 * An object responsible for transferring selected files to the current and removing the original 31 * files if {@link FileTransferService.KEEP_ORIGINAL_EXTRA} is set to false. This class is designed 32 * to run within the private profile user, ensuring that transferred files are stored inside the 33 * private space. 34 */ 35 class FileTransferManagerImpl( 36 private var notificationsHelper: NotificationsHelper, 37 context: Context, 38 ) : IFileTransferManager { 39 private val contentResolver = context.contentResolver 40 41 companion object { 42 private const val TAG: String = "FileTransferImpl" 43 private const val DEFAULT_MIMETYPE = "application/octet-stream" 44 private const val PROGRESS_NOTIFICATION_UPDATE_INTERVAL_MS: Long = 1000L 45 private const val BUFFER_SIZE = 1024 46 } 47 48 override suspend fun transferFiles( 49 uris: List<Uri>, 50 keepOriginal: Boolean, 51 destinationPath: String, 52 ) { 53 val numberOfFiles: Int = uris.size 54 var progress: Int = 0 55 var copiedBytes: Long = 0L 56 57 val totalBytes = 58 try { 59 calculateTotalSize(uris) 60 } catch (e: FileNotFoundException) { 61 // TODO(b/394024024) Notify user that the transfer could not be completed 62 Log.e(TAG, "transferFiles: Unable to get the total size of the files. ", e) 63 return 64 } 65 66 // TODO(b/394024024) Files size and available storage checks 67 68 // Copy/Move each individual file 69 for (sourceUri in uris) { 70 val metadata = getFileMetadata(sourceUri) 71 if (metadata == null) { 72 // TODO(b/401000421): Deliver a notification to the user about this failed file 73 Log.e(TAG, "Unable to get metadata for uri: $sourceUri") 74 continue 75 } 76 77 val displayName = metadata.displayName 78 val mimeType = metadata.mimeType 79 80 val newUri = createNewMediaEntry(displayName, mimeType, destinationPath) 81 if (newUri == null) { 82 // TODO(b/401000421): Deliver a notification to the user about this failed file 83 Log.e(TAG, "Failed to create new media entry for: $displayName") 84 continue 85 } 86 87 // TODO(b/403206691): We should probably do this operation in parallel 88 copiedBytes += 89 copySingleFile(sourceUri, newUri, totalBytes) { newProgress -> 90 if (newProgress - progress >= 1) { 91 progress = newProgress 92 notificationsHelper.updateProgressNotification( 93 progress, 94 numberOfFiles, 95 keepOriginal, 96 ) 97 } 98 } 99 100 removeFileIfRequired(sourceUri, keepOriginal) 101 } 102 notificationsHelper.displayCompletionNotification(numberOfFiles, keepOriginal) 103 } 104 105 /** Retrieves file metadata such as display name and MIME type. */ 106 private fun getFileMetadata(uri: Uri): FileMetadata? { 107 val projection = 108 arrayOf( 109 DocumentsContract.Document.COLUMN_DISPLAY_NAME, 110 DocumentsContract.Document.COLUMN_DOCUMENT_ID, 111 ) 112 113 contentResolver.query(uri, projection, null, null)?.use { cursor -> 114 if (cursor.moveToFirst()) { 115 try { 116 val displayName = 117 cursor.getString( 118 cursor.getColumnIndexOrThrow( 119 DocumentsContract.Document.COLUMN_DISPLAY_NAME 120 ) 121 ) 122 val mimeType: String = contentResolver.getType(uri) ?: DEFAULT_MIMETYPE 123 return FileMetadata(displayName, mimeType) 124 } catch (e: IllegalArgumentException) { 125 Log.e(TAG, "getFileMetadata: Could not retrieve the file name ", e) 126 } 127 } 128 } ?: Log.e(TAG, "Failed to query URI: $uri") 129 return null 130 } 131 132 /** Creates a new media entry in MediaStore. */ 133 private fun createNewMediaEntry( 134 displayName: String, 135 mimeType: String, 136 destinationPath: String, 137 ): Uri? { 138 return contentResolver.insert( 139 MediaStore.Downloads.EXTERNAL_CONTENT_URI, 140 ContentValues().apply { 141 put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) 142 put(MediaStore.MediaColumns.MIME_TYPE, mimeType) 143 put(MediaStore.MediaColumns.RELATIVE_PATH, destinationPath) 144 }, 145 ) 146 } 147 148 /** 149 * Copies a file from sourceUri to destinationUri and tracks progress. 150 * 151 * @return total bytes of this file copied 152 */ 153 private suspend fun copySingleFile( 154 sourceUri: Uri, 155 destinationUri: Uri, 156 totalBytes: Long, 157 onProgressUpdate: (Int) -> Unit, 158 ): Long { 159 var copiedBytes = 0L 160 161 try { 162 contentResolver.openInputStream(sourceUri)?.use { inputStream -> 163 contentResolver.openOutputStream(destinationUri)?.use { outputStream -> 164 val buffer = ByteArray(BUFFER_SIZE) 165 var length: Int 166 var lastUpdated = SystemClock.elapsedRealtime() 167 168 while (inputStream.read(buffer).also { length = it } > 0) { 169 outputStream.write(buffer, 0, length) 170 copiedBytes += length 171 172 val newProgress = 173 if (totalBytes == 0L) { 174 100 175 } else { 176 ((copiedBytes * 100) / totalBytes).toInt() 177 } 178 val currentTime = SystemClock.elapsedRealtime() 179 180 if (currentTime - lastUpdated > PROGRESS_NOTIFICATION_UPDATE_INTERVAL_MS) { 181 onProgressUpdate(newProgress) 182 lastUpdated = currentTime 183 } 184 } 185 } 186 ?: { 187 // TODO(b/401000421): Maybe deliver a notification to the user about this 188 // failed file 189 Log.e(TAG, "Failed to open output stream for URI: $destinationUri") 190 } 191 } 192 ?: { 193 // TODO(b/401000421): Maybe deliver a notification to the user about this failed 194 // file 195 Log.e(TAG, "Failed to open input stream for URI: $sourceUri") 196 } 197 } catch (e: IOException) { 198 // TODO(b/401000421): Maybe deliver a notification to the user about this failed file 199 Log.e(TAG, "Error copying file: ${e.message}") 200 } 201 202 return copiedBytes 203 } 204 205 @Throws(FileNotFoundException::class) 206 private fun calculateTotalSize(uris: List<Uri>): Long { 207 var totalBytes: Long = 0 208 for (fileUri in uris) { 209 totalBytes += contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0L 210 } 211 return totalBytes 212 } 213 214 private fun removeFileIfRequired(sourceUri: Uri, keepOriginal: Boolean) { 215 if (!keepOriginal) { 216 try { 217 DocumentsContract.deleteDocument(contentResolver, sourceUri) 218 } catch (e: FileNotFoundException) { 219 // TODO(b/394024024) Handle this gracefully 220 Log.e(TAG, "Unable to remove the original file: ${e.message}") 221 } 222 } 223 } 224 } 225