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