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