• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2024 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.photopicker.data
18 
19 import android.content.ContentResolver
20 import android.content.Intent
21 import android.database.Cursor
22 import android.net.Uri
23 import android.os.Bundle
24 import androidx.core.os.bundleOf
25 import androidx.paging.PagingSource.LoadResult
26 import com.android.photopicker.data.model.Group
27 import com.android.photopicker.data.model.Media
28 import com.android.photopicker.data.model.MediaPageKey
29 import com.android.photopicker.data.model.MediaSource
30 import com.android.photopicker.data.model.Provider
31 import com.android.photopicker.extensions.getPhotopickerMimeTypes
32 
33 /**
34  * A client class that is reponsible for holding logic required to interact with [MediaProvider].
35  *
36  * It typically fetches data from [MediaProvider] using content queries and call methods.
37  */
38 open class MediaProviderClient {
39     companion object {
40         private const val TAG = "MediaProviderClient"
41         private const val MEDIA_INIT_CALL_METHOD: String = "picker_media_init"
42         private const val EXTRA_MIME_TYPES = "mime_types"
43         private const val EXTRA_INTENT_ACTION = "intent_action"
44         private const val EXTRA_LOCAL_ONLY = "is_local_only"
45         private const val EXTRA_ALBUM_ID = "album_id"
46         private const val EXTRA_ALBUM_AUTHORITY = "album_authority"
47     }
48 
49     /** Contains all optional and mandatory keys required to make a Media query */
50     private enum class MediaQuery(val key: String) {
51         PICKER_ID("picker_id"),
52         DATE_TAKEN("date_taken_millis"),
53         PAGE_SIZE("page_size"),
54         PROVIDERS("providers"),
55     }
56 
57     /**
58      * Contains all mandatory keys required to make an Album Media query that are not present in
59      * [MediaQuery] already.
60      */
61     private enum class AlbumMediaQuery(val key: String) {
62         ALBUM_AUTHORITY("album_authority"),
63     }
64 
65     /**
66      * Contains all optional and mandatory keys for data in the Available Providers query response.
67      */
68     enum class AvailableProviderResponse(val key: String) {
69         AUTHORITY("authority"),
70         MEDIA_SOURCE("media_source"),
71         UID("uid"),
72     }
73 
74     /** Contains all optional and mandatory keys for data in the Media query response. */
75     enum class MediaResponse(val key: String) {
76         MEDIA_ID("id"),
77         PICKER_ID("picker_id"),
78         AUTHORITY("authority"),
79         MEDIA_SOURCE("media_source"),
80         MEDIA_URI("wrapped_uri"),
81         LOADABLE_URI("unwrapped_uri"),
82         DATE_TAKEN("date_taken_millis"),
83         SIZE("size_bytes"),
84         MIME_TYPE("mime_type"),
85         STANDARD_MIME_TYPE_EXT("standard_mime_type_extension"),
86         DURATION("duration_millis"),
87     }
88 
89     /** Contains all optional and mandatory keys for data in the Media query response extras. */
90     enum class MediaResponseExtras(val key: String) {
91         PREV_PAGE_ID("prev_page_picker_id"),
92         PREV_PAGE_DATE_TAKEN("prev_page_date_taken"),
93         NEXT_PAGE_ID("next_page_picker_id"),
94         NEXT_PAGE_DATE_TAKEN("next_page_date_taken"),
95     }
96 
97     /** Contains all optional and mandatory keys for data in the Media query response. */
98     enum class AlbumResponse(val key: String) {
99         ALBUM_ID("id"),
100         PICKER_ID("picker_id"),
101         AUTHORITY("authority"),
102         DATE_TAKEN("date_taken_millis"),
103         ALBUM_NAME("display_name"),
104         UNWRAPPED_COVER_URI("unwrapped_cover_uri"),
105         COVER_MEDIA_SOURCE("media_source")
106     }
107 
108     /** Fetch available [Provider]-s from the Media Provider process. */
109     fun fetchAvailableProviders(
110         contentResolver: ContentResolver,
111     ): List<Provider> {
112         try {
113             contentResolver
114                 .query(
115                     AVAILABLE_PROVIDERS_URI,
116                     /* projection */ null,
117                     /* queryArgs */ null,
118                     /* cancellationSignal */ null // TODO
119                 )
120                 .use { cursor ->
121                     return getListOfProviders(cursor!!)
122                 }
123         } catch (e: RuntimeException) {
124             throw RuntimeException("Could not fetch available providers", e)
125         }
126     }
127 
128     /** Fetch a list of [Media] from MediaProvider for the given page key. */
129     fun fetchMedia(
130         pageKey: MediaPageKey,
131         pageSize: Int,
132         contentResolver: ContentResolver,
133         availableProviders: List<Provider>,
134         intent: Intent?,
135     ): LoadResult<MediaPageKey, Media> {
136         val input: Bundle =
137             bundleOf(
138                 MediaQuery.PICKER_ID.key to pageKey.pickerId,
139                 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis,
140                 MediaQuery.PAGE_SIZE.key to pageSize,
141                 MediaQuery.PROVIDERS.key to
142                     ArrayList<String>().apply {
143                         availableProviders.forEach { provider -> add(provider.authority) }
144                     },
145                 EXTRA_MIME_TYPES to intent?.getPhotopickerMimeTypes(),
146                 EXTRA_INTENT_ACTION to intent?.action
147             )
148 
149         try {
150             return contentResolver
151                 .query(
152                     MEDIA_URI,
153                     /* projection */ null,
154                     input,
155                     /* cancellationSignal */ null // TODO
156                 )
157                 .use { cursor ->
158                     cursor?.let {
159                         LoadResult.Page(
160                             data = cursor.getListOfMedia(),
161                             prevKey = cursor.getPrevPageKey(),
162                             nextKey = cursor.getNextPageKey()
163                         )
164                     }
165                         ?: throw IllegalStateException(
166                             "Received a null response from Content Provider"
167                         )
168                 }
169         } catch (e: RuntimeException) {
170             throw RuntimeException("Could not fetch media", e)
171         }
172     }
173 
174     /** Fetch a list of [Group.Album] from MediaProvider for the given page key. */
175     fun fetchAlbums(
176         pageKey: MediaPageKey,
177         pageSize: Int,
178         contentResolver: ContentResolver,
179         availableProviders: List<Provider>,
180         intent: Intent?
181     ): LoadResult<MediaPageKey, Group.Album> {
182         val input: Bundle =
183             bundleOf(
184                 MediaQuery.PICKER_ID.key to pageKey.pickerId,
185                 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis,
186                 MediaQuery.PAGE_SIZE.key to pageSize,
187                 MediaQuery.PROVIDERS.key to
188                     ArrayList<String>().apply {
189                         availableProviders.forEach { provider -> add(provider.authority) }
190                     },
191                 EXTRA_MIME_TYPES to intent?.getPhotopickerMimeTypes(),
192                 EXTRA_INTENT_ACTION to intent?.action
193             )
194 
195         try {
196             return contentResolver
197                 .query(
198                     ALBUM_URI,
199                     /* projection */ null,
200                     input,
201                     /* cancellationSignal */ null // TODO
202                 )
203                 .use { cursor ->
204                     cursor?.let {
205                         LoadResult.Page(
206                             data = cursor.getListOfAlbums(),
207                             prevKey = cursor.getPrevPageKey(),
208                             nextKey = cursor.getNextPageKey()
209                         )
210                     }
211                         ?: throw IllegalStateException(
212                             "Received a null response from Content Provider"
213                         )
214                 }
215         } catch (e: RuntimeException) {
216             throw RuntimeException("Could not fetch albums", e)
217         }
218     }
219 
220     /** Fetch a list of [Media] from MediaProvider for the given page key. */
221     fun fetchAlbumMedia(
222         albumId: String,
223         albumAuthority: String,
224         pageKey: MediaPageKey,
225         pageSize: Int,
226         contentResolver: ContentResolver,
227         availableProviders: List<Provider>,
228         intent: Intent?
229     ): LoadResult<MediaPageKey, Media> {
230         val input: Bundle =
231             bundleOf(
232                 AlbumMediaQuery.ALBUM_AUTHORITY.key to albumAuthority,
233                 MediaQuery.PICKER_ID.key to pageKey.pickerId,
234                 MediaQuery.DATE_TAKEN.key to pageKey.dateTakenMillis,
235                 MediaQuery.PAGE_SIZE.key to pageSize,
236                 MediaQuery.PROVIDERS.key to
237                     ArrayList<String>().apply {
238                         availableProviders.forEach { provider -> add(provider.authority) }
239                     },
240                 EXTRA_MIME_TYPES to intent?.getPhotopickerMimeTypes(),
241                 EXTRA_INTENT_ACTION to intent?.action
242             )
243 
244         try {
245             return contentResolver
246                 .query(
247                     getAlbumMediaUri(albumId),
248                     /* projection */ null,
249                     input,
250                     /* cancellationSignal */ null // TODO
251                 )
252                 .use { cursor ->
253                     cursor?.let {
254                         LoadResult.Page(
255                             data = cursor.getListOfMedia(),
256                             prevKey = cursor.getPrevPageKey(),
257                             nextKey = cursor.getNextPageKey()
258                         )
259                     }
260                         ?: throw IllegalStateException(
261                             "Received a null response from Content Provider"
262                         )
263                 }
264         } catch (e: RuntimeException) {
265             throw RuntimeException("Could not fetch album media", e)
266         }
267     }
268 
269     /**
270      * Send a refresh media request to MediaProvider. This is a signal for MediaProvider to refresh
271      * its cache, if required.
272      */
273     fun refreshMedia(
274         @Suppress("UNUSED_PARAMETER") providers: List<Provider>,
275         resolver: ContentResolver,
276         intent: Intent?
277     ) {
278         val extras = Bundle()
279 
280         // TODO(b/340246010): Currently, we trigger sync for all providers. This is because
281         //  the UI is responsible for triggering syncs which is sometimes required to enable
282         //  providers. This should be changed to triggering syncs for specific providers once the
283         //  backend takes responsibility for the sync triggers.
284         val initLocalOnlyMedia = false
285 
286         extras.putBoolean(EXTRA_LOCAL_ONLY, initLocalOnlyMedia)
287         extras.putStringArrayList(EXTRA_MIME_TYPES, intent?.getPhotopickerMimeTypes())
288         extras.putString(EXTRA_INTENT_ACTION, intent?.action)
289         refreshMedia(extras, resolver)
290     }
291 
292     /**
293      * Send a refresh album media request to MediaProvider. This is a signal for MediaProvider to
294      * refresh its cache for the given album media, if required.
295      */
296     fun refreshAlbumMedia(
297         albumId: String,
298         albumAuthority: String,
299         providers: List<Provider>,
300         resolver: ContentResolver,
301         intent: Intent?
302     ) {
303         val extras = Bundle()
304         val initLocalOnlyMedia: Boolean =
305             providers.all { provider -> (provider.mediaSource == MediaSource.LOCAL) }
306         extras.putBoolean(EXTRA_LOCAL_ONLY, initLocalOnlyMedia)
307         extras.putStringArrayList(EXTRA_MIME_TYPES, intent?.getPhotopickerMimeTypes())
308         extras.putString(EXTRA_INTENT_ACTION, intent?.action)
309         extras.putString(EXTRA_ALBUM_ID, albumId)
310         extras.putString(EXTRA_ALBUM_AUTHORITY, albumAuthority)
311         refreshMedia(extras, resolver)
312     }
313 
314     /** Creates a list of [Provider] from the given [Cursor]. */
315     private fun getListOfProviders(cursor: Cursor): List<Provider> {
316         val result: MutableList<Provider> = mutableListOf<Provider>()
317         if (cursor.moveToFirst()) {
318             do {
319                 result.add(
320                     Provider(
321                         authority =
322                             cursor.getString(
323                                 cursor.getColumnIndexOrThrow(
324                                     AvailableProviderResponse.AUTHORITY.key
325                                 )
326                             ),
327                         mediaSource =
328                             MediaSource.valueOf(
329                                 cursor.getString(
330                                     cursor.getColumnIndexOrThrow(
331                                         AvailableProviderResponse.MEDIA_SOURCE.key
332                                     )
333                                 )
334                             ),
335                         uid =
336                             cursor.getInt(
337                                 cursor.getColumnIndexOrThrow(AvailableProviderResponse.UID.key)
338                             ),
339                     )
340                 )
341             } while (cursor.moveToNext())
342         }
343 
344         return result
345     }
346 
347     /**
348      * Creates a list of [Media] from the given [Cursor].
349      *
350      * [Media] can be of type [Media.Image] or [Media.Video].
351      */
352     private fun Cursor.getListOfMedia(): List<Media> {
353         val result: MutableList<Media> = mutableListOf<Media>()
354         if (this.moveToFirst()) {
355             do {
356                 val mediaId: String = getString(getColumnIndexOrThrow(MediaResponse.MEDIA_ID.key))
357                 val pickerId: Long = getLong(getColumnIndexOrThrow(MediaResponse.PICKER_ID.key))
358                 val authority: String =
359                     getString(getColumnIndexOrThrow(MediaResponse.AUTHORITY.key))
360                 val mediaSource: MediaSource =
361                     MediaSource.valueOf(
362                         getString(getColumnIndexOrThrow(MediaResponse.MEDIA_SOURCE.key))
363                     )
364                 val mediaUri: Uri =
365                     Uri.parse(getString(getColumnIndexOrThrow(MediaResponse.MEDIA_URI.key)))
366                 val loadableUri: Uri =
367                     Uri.parse(getString(getColumnIndexOrThrow(MediaResponse.LOADABLE_URI.key)))
368                 val dateTakenMillisLong: Long =
369                     getLong(getColumnIndexOrThrow(MediaResponse.DATE_TAKEN.key))
370                 val sizeInBytes: Long = getLong(getColumnIndexOrThrow(MediaResponse.SIZE.key))
371                 val mimeType: String = getString(getColumnIndexOrThrow(MediaResponse.MIME_TYPE.key))
372                 val standardMimeTypeExtension: Int =
373                     getInt(getColumnIndexOrThrow(MediaResponse.STANDARD_MIME_TYPE_EXT.key))
374 
375                 if (mimeType.startsWith("image/")) {
376                     result.add(
377                         Media.Image(
378                             mediaId = mediaId,
379                             pickerId = pickerId,
380                             authority = authority,
381                             mediaSource = mediaSource,
382                             mediaUri = mediaUri,
383                             glideLoadableUri = loadableUri,
384                             dateTakenMillisLong = dateTakenMillisLong,
385                             sizeInBytes = sizeInBytes,
386                             mimeType = mimeType,
387                             standardMimeTypeExtension = standardMimeTypeExtension,
388                         )
389                     )
390                 } else if (mimeType.startsWith("video/")) {
391                     result.add(
392                         Media.Video(
393                             mediaId = mediaId,
394                             pickerId = pickerId,
395                             authority = authority,
396                             mediaSource = mediaSource,
397                             mediaUri = mediaUri,
398                             glideLoadableUri = loadableUri,
399                             dateTakenMillisLong = dateTakenMillisLong,
400                             sizeInBytes = sizeInBytes,
401                             mimeType = mimeType,
402                             standardMimeTypeExtension = standardMimeTypeExtension,
403                             duration = getInt(getColumnIndexOrThrow(MediaResponse.DURATION.key)),
404                         )
405                     )
406                 } else {
407                     throw UnsupportedOperationException("Could not recognize mime type $mimeType")
408                 }
409             } while (moveToNext())
410         }
411 
412         return result
413     }
414 
415     /**
416      * Extracts the previous page key from the given [Cursor]. In case the cursor contains the
417      * contents of the first page, the previous page key will be null.
418      */
419     private fun Cursor.getPrevPageKey(): MediaPageKey? {
420         val id: Long = extras.getLong(MediaResponseExtras.PREV_PAGE_ID.key, Long.MIN_VALUE)
421         val date: Long =
422             extras.getLong(MediaResponseExtras.PREV_PAGE_DATE_TAKEN.key, Long.MIN_VALUE)
423         return if (date == Long.MIN_VALUE) {
424             null
425         } else {
426             MediaPageKey(pickerId = id, dateTakenMillis = date)
427         }
428     }
429 
430     /**
431      * Extracts the next page key from the given [Cursor]. In case the cursor contains the contents
432      * of the last page, the next page key will be null.
433      */
434     private fun Cursor.getNextPageKey(): MediaPageKey? {
435         val id: Long = extras.getLong(MediaResponseExtras.NEXT_PAGE_ID.key, Long.MIN_VALUE)
436         val date: Long =
437             extras.getLong(MediaResponseExtras.NEXT_PAGE_DATE_TAKEN.key, Long.MIN_VALUE)
438         return if (date == Long.MIN_VALUE) {
439             null
440         } else {
441             MediaPageKey(pickerId = id, dateTakenMillis = date)
442         }
443     }
444 
445     /** Creates a list of [Group.Album]-s from the given [Cursor]. */
446     private fun Cursor.getListOfAlbums(): List<Group.Album> {
447         val result: MutableList<Group.Album> = mutableListOf<Group.Album>()
448 
449         if (this.moveToFirst()) {
450             do {
451                 val albumId = getString(getColumnIndexOrThrow(AlbumResponse.ALBUM_ID.key))
452                 result.add(
453                     Group.Album(
454                         id = albumId,
455                         // This is a temporary solution till we cache album data in Picker DB
456                         pickerId = albumId.hashCode().toLong(),
457                         authority = getString(getColumnIndexOrThrow(AlbumResponse.AUTHORITY.key)),
458                         dateTakenMillisLong =
459                             getLong(getColumnIndexOrThrow(AlbumResponse.DATE_TAKEN.key)),
460                         displayName =
461                             getString(getColumnIndexOrThrow(AlbumResponse.ALBUM_NAME.key)),
462                         coverUri =
463                             Uri.parse(
464                                 getString(
465                                     getColumnIndexOrThrow(AlbumResponse.UNWRAPPED_COVER_URI.key)
466                                 )
467                             ),
468                         coverMediaSource =
469                             MediaSource.valueOf(
470                                 getString(
471                                     getColumnIndexOrThrow(AlbumResponse.COVER_MEDIA_SOURCE.key)
472                                 )
473                             )
474                     )
475                 )
476             } while (moveToNext())
477         }
478 
479         return result
480     }
481 
482     /**
483      * Send a refresh [Media] request to MediaProvider with the prepared input args. This is a
484      * signal for MediaProvider to refresh its cache, if required.
485      */
486     private fun refreshMedia(extras: Bundle, contentResolver: ContentResolver) {
487         try {
488             contentResolver.call(
489                 MEDIA_PROVIDER_AUTHORITY,
490                 MEDIA_INIT_CALL_METHOD,
491                 /* arg */ null,
492                 extras
493             )
494         } catch (e: RuntimeException) {
495             throw RuntimeException("Could not send refresh media call to Media Provider $extras", e)
496         }
497     }
498 }
499