• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2023 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.intentresolver.contentpreview
18 
19 import android.content.ContentInterface
20 import android.content.Intent
21 import android.database.Cursor
22 import android.media.MediaMetadata
23 import android.net.Uri
24 import android.provider.DocumentsContract
25 import android.provider.DocumentsContract.Document.FLAG_SUPPORTS_THUMBNAIL
26 import android.provider.Downloads
27 import android.provider.OpenableColumns
28 import android.text.TextUtils
29 import android.util.Log
30 import androidx.annotation.OpenForTesting
31 import androidx.annotation.VisibleForTesting
32 import androidx.lifecycle.Lifecycle
33 import androidx.lifecycle.coroutineScope
34 import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_FILE
35 import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_IMAGE
36 import com.android.intentresolver.contentpreview.ContentPreviewType.CONTENT_PREVIEW_TEXT
37 import com.android.intentresolver.measurements.runTracing
38 import com.android.intentresolver.util.ownedByCurrentUser
39 import java.util.concurrent.atomic.AtomicInteger
40 import java.util.function.Consumer
41 import kotlinx.coroutines.CancellationException
42 import kotlinx.coroutines.CompletableDeferred
43 import kotlinx.coroutines.CoroutineScope
44 import kotlinx.coroutines.async
45 import kotlinx.coroutines.coroutineScope
46 import kotlinx.coroutines.flow.Flow
47 import kotlinx.coroutines.flow.MutableSharedFlow
48 import kotlinx.coroutines.flow.SharedFlow
49 import kotlinx.coroutines.flow.take
50 import kotlinx.coroutines.isActive
51 import kotlinx.coroutines.launch
52 import kotlinx.coroutines.runBlocking
53 import kotlinx.coroutines.withTimeoutOrNull
54 
55 /**
56  * A set of metadata columns we read for a content URI (see
57  * [PreviewDataProvider.UriRecord.readQueryResult] method).
58  */
59 @VisibleForTesting
60 val METADATA_COLUMNS =
61     arrayOf(
62         DocumentsContract.Document.COLUMN_FLAGS,
63         MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI,
64         OpenableColumns.DISPLAY_NAME,
65         Downloads.Impl.COLUMN_TITLE
66     )
67 private const val TIMEOUT_MS = 1_000L
68 
69 /**
70  * Asynchronously loads and stores shared URI metadata (see [Intent.EXTRA_STREAM]) such as mime
71  * type, file name, and a preview thumbnail URI.
72  */
73 @OpenForTesting
74 open class PreviewDataProvider
75 @JvmOverloads
76 constructor(
77     private val scope: CoroutineScope,
78     private val targetIntent: Intent,
79     private val contentResolver: ContentInterface,
80     private val typeClassifier: MimeTypeClassifier = DefaultMimeTypeClassifier,
81 ) {
82 
83     private val records = targetIntent.contentUris.map { UriRecord(it) }
84 
85     private val fileInfoSharedFlow: SharedFlow<FileInfo> by lazy {
86         // Alternatively, we could just use [shareIn()] on a [flow] -- and it would be, arguably,
87         //  cleaner -- but we'd lost the ability to trace the traverse as [runTracing] does not
88         //  generally work over suspend function invocations.
89         MutableSharedFlow<FileInfo>(replay = records.size).apply {
90             scope.launch {
91                 runTracing("image-preview-metadata") {
92                     for (record in records) {
93                         tryEmit(FileInfo.Builder(record.uri).readFromRecord(record).build())
94                     }
95                 }
96             }
97         }
98     }
99 
100     /** returns number of shared URIs, see [Intent.EXTRA_STREAM] */
101     @get:OpenForTesting
102     open val uriCount: Int
103         get() = records.size
104 
105     /**
106      * Returns a [Flow] of [FileInfo], for each shared URI in order, with [FileInfo.mimeType] and
107      * [FileInfo.previewUri] set (a data projection tailored for the image preview UI).
108      */
109     @get:OpenForTesting
110     open val imagePreviewFileInfoFlow: Flow<FileInfo>
111         get() = fileInfoSharedFlow.take(records.size)
112 
113     /**
114      * Preview type to use. The type is determined asynchronously with a timeout; the fall-back
115      * values is [ContentPreviewType.CONTENT_PREVIEW_FILE]
116      */
117     @get:OpenForTesting
118     @get:ContentPreviewType
119     open val previewType: Int by lazy {
120         runTracing("preview-type") {
121             /* In [android.content.Intent#getType], the app may specify a very general mime type
122              * that broadly covers all data being shared, such as '*' when sending an image
123              * and text. We therefore should inspect each item for the preferred type, in order:
124              * IMAGE, FILE, TEXT. */
125             if (!targetIntent.isSend || records.isEmpty()) {
126                 CONTENT_PREVIEW_TEXT
127             } else {
128                 try {
129                     runBlocking(scope.coroutineContext) {
130                         withTimeoutOrNull(TIMEOUT_MS) { scope.async { loadPreviewType() }.await() }
131                             ?: CONTENT_PREVIEW_FILE
132                     }
133                 } catch (e: CancellationException) {
134                     Log.w(
135                         ContentPreviewUi.TAG,
136                         "An attempt to read preview type from a cancelled scope",
137                         e
138                     )
139                     CONTENT_PREVIEW_FILE
140                 }
141             }
142         }
143     }
144 
145     /**
146      * The first shared URI's metadata. This call wait's for the data to be loaded and falls back to
147      * a crude value if the data is not loaded within a time limit.
148      */
149     open val firstFileInfo: FileInfo? by lazy {
150         runTracing("first-uri-metadata") {
151             records.firstOrNull()?.let { record ->
152                 val builder = FileInfo.Builder(record.uri)
153                 try {
154                     runBlocking(scope.coroutineContext) {
155                         withTimeoutOrNull(TIMEOUT_MS) {
156                             scope.async { builder.readFromRecord(record) }.await()
157                         }
158                     }
159                 } catch (e: CancellationException) {
160                     Log.w(
161                         ContentPreviewUi.TAG,
162                         "An attempt to read first file info from a cancelled scope",
163                         e
164                     )
165                 }
166                 builder.build()
167             }
168         }
169     }
170 
171     private fun FileInfo.Builder.readFromRecord(record: UriRecord): FileInfo.Builder {
172         withMimeType(record.mimeType)
173         val previewUri =
174             when {
175                 record.isImageType || record.supportsImageType || record.supportsThumbnail ->
176                     record.uri
177                 else -> record.iconUri
178             }
179         withPreviewUri(previewUri)
180         return this
181     }
182 
183     /**
184      * Returns a title for the first shared URI which is read from URI metadata or, if the metadata
185      * is not provided, derived from the URI.
186      */
187     @Throws(IndexOutOfBoundsException::class)
188     fun getFirstFileName(callerLifecycle: Lifecycle, callback: Consumer<String>) {
189         if (records.isEmpty()) {
190             throw IndexOutOfBoundsException("There are no shared URIs")
191         }
192         callerLifecycle.coroutineScope.launch {
193             val result = scope.async { getFirstFileName() }.await()
194             callback.accept(result)
195         }
196     }
197 
198     @Throws(IndexOutOfBoundsException::class)
199     private fun getFirstFileName(): String {
200         if (records.isEmpty()) throw IndexOutOfBoundsException("There are no shared URIs")
201 
202         val record = records[0]
203         return if (TextUtils.isEmpty(record.title)) getFileName(record.uri) else record.title
204     }
205 
206     @ContentPreviewType
207     private suspend fun loadPreviewType(): Int {
208         // Execute [ContentResolver#getType()] calls sequentially as the method contains a timeout
209         // logic for the actual [ContentProvider#getType] call. Thus it is possible for one getType
210         // call's timeout work against other concurrent getType calls e.g. when a two concurrent
211         // calls on the caller side are scheduled on the same thread on the callee side.
212         records
213             .firstOrNull { it.isImageType }
214             ?.run {
215                 return CONTENT_PREVIEW_IMAGE
216             }
217 
218         val resultDeferred = CompletableDeferred<Int>()
219         return coroutineScope {
220             val job = launch {
221                 coroutineScope {
222                     val nextIndex = AtomicInteger(0)
223                     repeat(4) {
224                         launch {
225                             while (isActive) {
226                                 val i = nextIndex.getAndIncrement()
227                                 if (i >= records.size) break
228                                 val hasPreview =
229                                     with(records[i]) {
230                                         supportsImageType || supportsThumbnail || iconUri != null
231                                     }
232                                 if (hasPreview) {
233                                     resultDeferred.complete(CONTENT_PREVIEW_IMAGE)
234                                     break
235                                 }
236                             }
237                         }
238                     }
239                 }
240                 resultDeferred.complete(CONTENT_PREVIEW_FILE)
241             }
242             resultDeferred.await().also { job.cancel() }
243         }
244     }
245 
246     /**
247      * Provides a lazy evaluation and caches results of [ContentInterface.getType],
248      * [ContentInterface.getStreamTypes], and [ContentInterface.query] methods for the given [uri].
249      */
250     private inner class UriRecord(val uri: Uri) {
251         val mimeType: String? by lazy { contentResolver.getTypeSafe(uri) }
252         val isImageType: Boolean
253             get() = typeClassifier.isImageType(mimeType)
254         val supportsImageType: Boolean by lazy {
255             contentResolver.getStreamTypesSafe(uri)?.firstOrNull(typeClassifier::isImageType) !=
256                 null
257         }
258         val supportsThumbnail: Boolean
259             get() = query.supportsThumbnail
260         val title: String
261             get() = query.title
262         val iconUri: Uri?
263             get() = query.iconUri
264 
265         private val query by lazy { readQueryResult() }
266 
267         private fun readQueryResult(): QueryResult {
268             val cursor =
269                 contentResolver.querySafe(uri)?.takeIf { it.moveToFirst() } ?: return QueryResult()
270 
271             var flagColIdx = -1
272             var displayIconUriColIdx = -1
273             var nameColIndex = -1
274             var titleColIndex = -1
275             // TODO: double-check why Cursor#getColumnInded didn't work
276             cursor.columnNames.forEachIndexed { i, columnName ->
277                 when (columnName) {
278                     DocumentsContract.Document.COLUMN_FLAGS -> flagColIdx = i
279                     MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI -> displayIconUriColIdx = i
280                     OpenableColumns.DISPLAY_NAME -> nameColIndex = i
281                     Downloads.Impl.COLUMN_TITLE -> titleColIndex = i
282                 }
283             }
284 
285             val supportsThumbnail =
286                 flagColIdx >= 0 && ((cursor.getInt(flagColIdx) and FLAG_SUPPORTS_THUMBNAIL) != 0)
287 
288             var title = ""
289             if (nameColIndex >= 0) {
290                 title = cursor.getString(nameColIndex) ?: ""
291             }
292             if (TextUtils.isEmpty(title) && titleColIndex >= 0) {
293                 title = cursor.getString(titleColIndex) ?: ""
294             }
295 
296             val iconUri =
297                 if (displayIconUriColIdx >= 0) {
298                     cursor.getString(displayIconUriColIdx)?.let(Uri::parse)
299                 } else {
300                     null
301                 }
302 
303             return QueryResult(supportsThumbnail, title, iconUri)
304         }
305     }
306 
307     private class QueryResult(
308         val supportsThumbnail: Boolean = false,
309         val title: String = "",
310         val iconUri: Uri? = null
311     )
312 }
313 
314 private val Intent.isSend: Boolean
315     get() =
actionnull316         action.let { action ->
317             Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action
318         }
319 
320 private val Intent.contentUris: ArrayList<Uri>
321     get() =
urisnull322         ArrayList<Uri>().also { uris ->
323             if (Intent.ACTION_SEND == action) {
324                 getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
325                     ?.takeIf { it.ownedByCurrentUser }
326                     ?.let { uris.add(it) }
327             } else {
328                 getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.fold(uris) { accumulator, uri
329                     ->
330                     if (uri.ownedByCurrentUser) {
331                         accumulator.add(uri)
332                     }
333                     accumulator
334                 }
335             }
336         }
337 
getFileNamenull338 private fun getFileName(uri: Uri): String {
339     val fileName = uri.path ?: return ""
340     val index = fileName.lastIndexOf('/')
341     return if (index < 0) {
342         fileName
343     } else {
344         fileName.substring(index + 1)
345     }
346 }
347 
getTypeSafenull348 private fun ContentInterface.getTypeSafe(uri: Uri): String? =
349     runTracing("getType") {
350         try {
351             getType(uri)
352         } catch (e: SecurityException) {
353             logProviderPermissionWarning(uri, "mime type")
354             null
355         } catch (t: Throwable) {
356             Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t)
357             null
358         }
359     }
360 
ContentInterfacenull361 private fun ContentInterface.getStreamTypesSafe(uri: Uri): Array<String>? =
362     runTracing("getStreamTypes") {
363         try {
364             getStreamTypes(uri, "*/*")
365         } catch (e: SecurityException) {
366             logProviderPermissionWarning(uri, "stream types")
367             null
368         } catch (t: Throwable) {
369             Log.e(ContentPreviewUi.TAG, "Failed to read stream types, uri: $uri", t)
370             null
371         }
372     }
373 
ContentInterfacenull374 private fun ContentInterface.querySafe(uri: Uri): Cursor? =
375     runTracing("query") {
376         try {
377             query(uri, METADATA_COLUMNS, null, null)
378         } catch (e: SecurityException) {
379             logProviderPermissionWarning(uri, "metadata")
380             null
381         } catch (t: Throwable) {
382             Log.e(ContentPreviewUi.TAG, "Failed to read metadata, uri: $uri", t)
383             null
384         }
385     }
386 
logProviderPermissionWarningnull387 private fun logProviderPermissionWarning(uri: Uri, dataName: String) {
388     // The ContentResolver already logs the exception. Log something more informative.
389     Log.w(
390         ContentPreviewUi.TAG,
391         "Could not read $uri $dataName. If a preview is desired, call Intent#setClipData() to" +
392             " ensure that the sharesheet is given permission."
393     )
394 }
395