• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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