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