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