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