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.database.Cursor
20 import android.database.MatrixCursor
21 import android.net.Uri
22 import android.os.Bundle
23 import android.os.CancellationSignal
24 import android.test.mock.MockContentProvider
25 import androidx.core.os.bundleOf
26 import com.android.photopicker.data.model.CategoryType
27 import com.android.photopicker.data.model.CollectionInfo
28 import com.android.photopicker.data.model.Group
29 import com.android.photopicker.data.model.Icon
30 import com.android.photopicker.data.model.Media
31 import com.android.photopicker.data.model.MediaSource
32 import com.android.photopicker.data.model.Provider
33 import com.android.photopicker.features.search.model.SearchSuggestion
34 import com.android.photopicker.features.search.model.SearchSuggestionType
35 import java.util.UUID
36 import java.util.stream.Collectors
37
38 /**
39 * A test utility that provides implementation for some MediaProvider queries.
40 *
41 * This will be used to wrap [ContentResolver] to intercept calls to it and re-route them to the
42 * internal mock this class holds.
43 *
44 * All not overridden / unimplemented operations will throw [UnsupportedOperationException].
45 */
46 val DEFAULT_PROVIDERS: List<Provider> =
47 listOf(
48 Provider(
49 authority = "test_authority",
50 mediaSource = MediaSource.LOCAL,
51 uid = 0,
52 displayName = "Test app",
53 )
54 )
55
56 val DEFAULT_COLLECTION_INFO: List<CollectionInfo> =
57 listOf(
58 CollectionInfo(
59 authority = "test_authority",
60 collectionId = "1",
61 accountName = "default@test.com",
62 )
63 )
64
65 val DEFAULT_MEDIA: List<Media> =
66 listOf(
67 createMediaImage(10),
68 createMediaImage(11),
69 createMediaImage(12),
70 createMediaImage(13),
71 createMediaImage(14),
72 )
73
74 val DEFAULT_ALBUMS: List<Group.Album> =
75 listOf(createAlbum("Favorites"), createAlbum("Downloads"), createAlbum("CloudAlbum"))
76
77 val DEFAULT_ALBUM_NAME = "album_id"
78
79 val DEFAULT_ALBUM_MEDIA: Map<String, List<Media>> = mapOf(DEFAULT_ALBUM_NAME to DEFAULT_MEDIA)
80
81 val DEFAULT_SEARCH_REQUEST_ID: Int = 100
82
83 val DEFAULT_SEARCH_SUGGESTIONS: List<SearchSuggestion> =
84 listOf(
85 SearchSuggestion(
86 mediaSetId = null,
87 authority = null,
88 type = SearchSuggestionType.HISTORY,
89 displayText = "Text",
90 icon = null,
91 ),
92 SearchSuggestion(
93 mediaSetId = "media-set-id-1",
94 authority = "cloud.provider",
95 type = SearchSuggestionType.FACE,
96 displayText = null,
97 icon = Icon(Uri.parse("content://cloud.provider/1234"), MediaSource.LOCAL),
98 ),
99 SearchSuggestion(
100 mediaSetId = "media-set-id-1",
101 authority = "local-provider",
102 type = SearchSuggestionType.TEXT,
103 displayText = "Text",
104 icon = null,
105 ),
106 )
107
108 val DEFAULT_CATEGORY: Group.Category =
109 createCategory(CategoryType.PEOPLE_AND_PETS, DEFAULT_PROVIDERS[0].authority)
110
111 val DEFAULT_CATEGORIES_AND_ALBUMS: List<Group> =
112 listOf(
113 createAlbum("Favorites"),
114 createAlbum("Downloads"),
115 DEFAULT_CATEGORY,
116 createAlbum("CloudAlbum"),
117 )
118
119 val DEFAULT_MEDIA_SETS: List<Group.MediaSet> =
120 listOf(createMediaSet("1"), createMediaSet("2"), createMediaSet("3"))
121
122 fun createMediaImage(pickerId: Long): Media {
123 return Media.Image(
124 mediaId = UUID.randomUUID().toString(),
125 pickerId = pickerId,
126 authority = "authority",
127 mediaSource = MediaSource.LOCAL,
128 mediaUri = Uri.parse("content://media/picker/authority/media/$pickerId"),
129 glideLoadableUri = Uri.parse("content://authority/media/$pickerId"),
130 dateTakenMillisLong = Long.MAX_VALUE,
131 sizeInBytes = 10,
132 mimeType = "image/*",
133 standardMimeTypeExtension = 0,
134 )
135 }
136
createAlbumnull137 fun createAlbum(albumId: String): Group.Album {
138 return Group.Album(
139 id = albumId,
140 pickerId = albumId.hashCode().toLong(),
141 authority = DEFAULT_PROVIDERS[0].authority,
142 dateTakenMillisLong = Long.MAX_VALUE,
143 displayName = albumId,
144 coverUri = Uri.parse("content://test_authority/$albumId"),
145 coverMediaSource = DEFAULT_PROVIDERS[0].mediaSource,
146 )
147 }
148
createCategorynull149 fun createCategory(type: CategoryType, authority: String): Group.Category {
150 return Group.Category(
151 id = "test_id_" + type.name,
152 pickerId = 0,
153 authority = authority,
154 displayName = type.name,
155 categoryType = type,
156 icons = listOf(Icon(Uri.parse("content://test_authority/id"), MediaSource.LOCAL)),
157 isLeafCategory = true,
158 )
159 }
160
createMediaSetnull161 fun createMediaSet(mediaSetId: String): Group.MediaSet {
162 return Group.MediaSet(
163 id = mediaSetId,
164 pickerId = mediaSetId.hashCode().toLong(),
165 authority = DEFAULT_PROVIDERS[0].authority,
166 displayName = mediaSetId,
167 icon = Icon(Uri.parse("content://test_authority/$mediaSetId"), MediaSource.LOCAL),
168 )
169 }
170
171 class TestMediaProvider(
172 var providers: List<Provider> = DEFAULT_PROVIDERS,
173 var collectionInfos: List<CollectionInfo> = DEFAULT_COLLECTION_INFO,
174 var media: List<Media> = DEFAULT_MEDIA,
175 var albums: List<Group.Album> = DEFAULT_ALBUMS,
176 var albumMedia: Map<String, List<Media>> = DEFAULT_ALBUM_MEDIA,
177 var searchRequestId: Int = DEFAULT_SEARCH_REQUEST_ID,
178 var searchSuggestions: List<SearchSuggestion> = DEFAULT_SEARCH_SUGGESTIONS,
179 var searchProviders: List<Provider>? = DEFAULT_PROVIDERS,
180 var parentCategory: Group.Category = DEFAULT_CATEGORY,
181 var categoriesAndAlbums: List<Group> = DEFAULT_CATEGORIES_AND_ALBUMS,
182 var mediaSets: List<Group.MediaSet> = DEFAULT_MEDIA_SETS,
183 ) : MockContentProvider() {
184 var lastRefreshMediaRequest: Bundle? = null
185 var TEST_GRANTS_COUNT = 2
186
querynull187 override fun query(
188 uri: Uri,
189 projection: Array<String>?,
190 queryArgs: Bundle?,
191 cancellationSignal: CancellationSignal?,
192 ): Cursor? {
193 return when (uri.lastPathSegment) {
194 AVAILABLE_PROVIDERS_PATH_SEGMENT -> getAvailableProviders()
195 COLLECTION_INFO_SEGMENT -> getCollectionInfo()
196 MEDIA_PATH_SEGMENT -> getMedia()
197 ALBUM_PATH_SEGMENT -> getAlbums()
198 MEDIA_GRANTS_COUNT_PATH_SEGMENT -> fetchMediaGrantsCount()
199 PRE_SELECTION_URI_PATH_SEGMENT -> fetchFilteredMedia(queryArgs)
200 SEARCH_SUGGESTIONS_PATH_SEGMENT -> getSearchSuggestions()
201 CATEGORIES_PATH_SEGMENT -> getCategoriesAndAlbums()
202 MEDIA_SETS_PATH_SEGMENT -> getMediaSets()
203 MEDIA_SET_CONTENTS_PATH_SEGMENT -> getMedia()
204 else -> {
205 val pathSegments: MutableList<String> = uri.getPathSegments()
206 if (pathSegments.size == 4 && pathSegments[2].equals(ALBUM_PATH_SEGMENT)) {
207 // Album media query
208 return getAlbumMedia(pathSegments[3])
209 } else if (
210 pathSegments.size == 4 && pathSegments[2].equals(SEARCH_MEDIA_PATH_SEGMENT)
211 ) {
212 // Search results media query
213 return getMedia()
214 } else {
215 throw UnsupportedOperationException("Could not recognize uri $uri")
216 }
217 }
218 }
219 }
220
callnull221 override fun call(authority: String, method: String, arg: String?, extras: Bundle?): Bundle? {
222 return when (method) {
223 MediaProviderClient.MEDIA_INIT_CALL_METHOD -> {
224 initMedia(extras)
225 null
226 }
227 MediaProviderClient.SEARCH_REQUEST_INIT_CALL_METHOD -> {
228 bundleOf(MediaProviderClient.SEARCH_REQUEST_ID to searchRequestId)
229 }
230 MediaProviderClient.GET_SEARCH_PROVIDERS_CALL_METHOD ->
231 bundleOf(
232 MediaProviderClient.SEARCH_PROVIDER_AUTHORITIES to
233 if (searchProviders == null) null
234 else
235 arrayListOf<String>().apply {
236 searchProviders?.map { it.authority }?.toCollection(this)
237 }
238 )
239 else -> throw UnsupportedOperationException("Could not recognize method $method")
240 }
241 }
242
243 /** Returns a [Cursor] with the providers currently in the [providers] list. */
getAvailableProvidersnull244 private fun getAvailableProviders(): Cursor {
245 val cursor =
246 MatrixCursor(
247 arrayOf(
248 MediaProviderClient.AvailableProviderResponse.AUTHORITY.key,
249 MediaProviderClient.AvailableProviderResponse.MEDIA_SOURCE.key,
250 MediaProviderClient.AvailableProviderResponse.UID.key,
251 MediaProviderClient.AvailableProviderResponse.DISPLAY_NAME.key,
252 )
253 )
254 providers.forEach { provider ->
255 cursor.addRow(
256 arrayOf(
257 provider.authority,
258 provider.mediaSource.name,
259 provider.uid.toString(),
260 provider.displayName,
261 )
262 )
263 }
264 return cursor
265 }
266
getCollectionInfonull267 private fun getCollectionInfo(): Cursor {
268 val cursor =
269 MatrixCursor(
270 arrayOf(
271 MediaProviderClient.CollectionInfoResponse.AUTHORITY.key,
272 MediaProviderClient.CollectionInfoResponse.COLLECTION_ID.key,
273 MediaProviderClient.CollectionInfoResponse.ACCOUNT_NAME.key,
274 )
275 )
276 cursor.setExtras(Bundle())
277 collectionInfos.forEach { collectionInfo ->
278 cursor.addRow(
279 arrayOf(
280 collectionInfo.authority,
281 collectionInfo.collectionId,
282 collectionInfo.accountName,
283 )
284 )
285 cursor
286 .getExtras()
287 .putParcelable(collectionInfo.authority, collectionInfo.accountConfigurationIntent)
288 }
289 return cursor
290 }
291
getMedianull292 private fun getMedia(mediaItems: List<Media> = media): Cursor {
293 val cursor =
294 MatrixCursor(
295 arrayOf(
296 MediaProviderClient.MediaResponse.MEDIA_ID.key,
297 MediaProviderClient.MediaResponse.PICKER_ID.key,
298 MediaProviderClient.MediaResponse.AUTHORITY.key,
299 MediaProviderClient.MediaResponse.MEDIA_SOURCE.key,
300 MediaProviderClient.MediaResponse.MEDIA_URI.key,
301 MediaProviderClient.MediaResponse.LOADABLE_URI.key,
302 MediaProviderClient.MediaResponse.DATE_TAKEN.key,
303 MediaProviderClient.MediaResponse.SIZE.key,
304 MediaProviderClient.MediaResponse.MIME_TYPE.key,
305 MediaProviderClient.MediaResponse.STANDARD_MIME_TYPE_EXT.key,
306 MediaProviderClient.MediaResponse.DURATION.key,
307 MediaProviderClient.MediaResponse.IS_PRE_GRANTED.key,
308 )
309 )
310 mediaItems.forEach { mediaItem ->
311 cursor.addRow(
312 arrayOf(
313 mediaItem.mediaId,
314 mediaItem.pickerId.toString(),
315 mediaItem.authority,
316 mediaItem.mediaSource.toString(),
317 mediaItem.mediaUri.toString(),
318 mediaItem.glideLoadableUri.toString(),
319 mediaItem.dateTakenMillisLong.toString(),
320 mediaItem.sizeInBytes.toString(),
321 mediaItem.mimeType,
322 mediaItem.standardMimeTypeExtension.toString(),
323 if (mediaItem is Media.Video) mediaItem.duration else "0",
324 if (mediaItem.isPreGranted) 1 else 0,
325 )
326 )
327 }
328 return cursor
329 }
330
fetchFilteredMedianull331 private fun fetchFilteredMedia(queryArgs: Bundle?, mediaItems: List<Media> = media): Cursor {
332 val ids =
333 queryArgs
334 ?.getStringArrayList("pre_selection_uris")
335 ?.stream()
336 ?.map { it -> Uri.parse(it).lastPathSegment }
337 ?.collect(Collectors.toList())
338 val cursor =
339 MatrixCursor(
340 arrayOf(
341 MediaProviderClient.MediaResponse.MEDIA_ID.key,
342 MediaProviderClient.MediaResponse.PICKER_ID.key,
343 MediaProviderClient.MediaResponse.AUTHORITY.key,
344 MediaProviderClient.MediaResponse.MEDIA_SOURCE.key,
345 MediaProviderClient.MediaResponse.MEDIA_URI.key,
346 MediaProviderClient.MediaResponse.LOADABLE_URI.key,
347 MediaProviderClient.MediaResponse.DATE_TAKEN.key,
348 MediaProviderClient.MediaResponse.SIZE.key,
349 MediaProviderClient.MediaResponse.MIME_TYPE.key,
350 MediaProviderClient.MediaResponse.STANDARD_MIME_TYPE_EXT.key,
351 MediaProviderClient.MediaResponse.DURATION.key,
352 MediaProviderClient.MediaResponse.IS_PRE_GRANTED.key,
353 )
354 )
355 mediaItems.forEach { mediaItem ->
356 if (ids != null) {
357 if (mediaItem.mediaId in ids) {
358 cursor.addRow(
359 arrayOf(
360 mediaItem.mediaId,
361 mediaItem.pickerId.toString(),
362 mediaItem.authority,
363 mediaItem.mediaSource.toString(),
364 mediaItem.mediaUri.toString(),
365 mediaItem.glideLoadableUri.toString(),
366 mediaItem.dateTakenMillisLong.toString(),
367 mediaItem.sizeInBytes.toString(),
368 mediaItem.mimeType,
369 mediaItem.standardMimeTypeExtension.toString(),
370 if (mediaItem is Media.Video) mediaItem.duration else "0",
371 if (mediaItem.isPreGranted) 1 else 0,
372 )
373 )
374 }
375 }
376 }
377 return cursor
378 }
379
getAlbumsnull380 private fun getAlbums(): Cursor {
381 val cursor =
382 MatrixCursor(
383 arrayOf(
384 MediaProviderClient.AlbumResponse.ALBUM_ID.key,
385 MediaProviderClient.AlbumResponse.PICKER_ID.key,
386 MediaProviderClient.AlbumResponse.AUTHORITY.key,
387 MediaProviderClient.AlbumResponse.DATE_TAKEN.key,
388 MediaProviderClient.AlbumResponse.ALBUM_NAME.key,
389 MediaProviderClient.AlbumResponse.UNWRAPPED_COVER_URI.key,
390 MediaProviderClient.AlbumResponse.COVER_MEDIA_SOURCE.key,
391 )
392 )
393 albums.forEach { album ->
394 cursor.addRow(
395 arrayOf(
396 album.id,
397 album.pickerId.toString(),
398 album.authority,
399 album.dateTakenMillisLong.toString(),
400 album.displayName,
401 album.coverUri.toString(),
402 album.coverMediaSource.toString(),
403 )
404 )
405 }
406 return cursor
407 }
408
fetchMediaGrantsCountnull409 private fun fetchMediaGrantsCount(): Cursor {
410 val cursor = MatrixCursor(arrayOf("grants_count"))
411 cursor.addRow(arrayOf(TEST_GRANTS_COUNT))
412 return cursor
413 }
414
getAlbumMedianull415 private fun getAlbumMedia(albumId: String): Cursor? {
416 return getMedia(albumMedia.getOrDefault(albumId, emptyList()))
417 }
418
initMedianull419 private fun initMedia(extras: Bundle?) {
420 lastRefreshMediaRequest = extras
421 }
422
getSearchSuggestionsnull423 private fun getSearchSuggestions(): Cursor {
424 val cursor =
425 MatrixCursor(
426 arrayOf(
427 MediaProviderClient.SearchSuggestionsResponse.AUTHORITY.key,
428 MediaProviderClient.SearchSuggestionsResponse.MEDIA_SET_ID.key,
429 MediaProviderClient.SearchSuggestionsResponse.SEARCH_TEXT.key,
430 MediaProviderClient.SearchSuggestionsResponse.COVER_MEDIA_URI.key,
431 MediaProviderClient.SearchSuggestionsResponse.SUGGESTION_TYPE.key,
432 )
433 )
434
435 searchSuggestions.forEach { suggestion ->
436 cursor.addRow(
437 arrayOf(
438 suggestion.authority,
439 suggestion.mediaSetId,
440 suggestion.displayText,
441 suggestion.icon,
442 suggestion.type.key,
443 )
444 )
445 }
446 return cursor
447 }
448
getCategoriesAndAlbumsnull449 private fun getCategoriesAndAlbums(): Cursor {
450 val cursor =
451 MatrixCursor(
452 arrayOf(
453 MediaProviderClient.GroupResponse.MEDIA_GROUP.key,
454 MediaProviderClient.GroupResponse.GROUP_ID.key,
455 MediaProviderClient.GroupResponse.PICKER_ID.key,
456 MediaProviderClient.GroupResponse.DISPLAY_NAME.key,
457 MediaProviderClient.GroupResponse.AUTHORITY.key,
458 MediaProviderClient.GroupResponse.UNWRAPPED_COVER_URI.key,
459 MediaProviderClient.GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_1.key,
460 MediaProviderClient.GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_2.key,
461 MediaProviderClient.GroupResponse.ADDITIONAL_UNWRAPPED_COVER_URI_3.key,
462 MediaProviderClient.GroupResponse.CATEGORY_TYPE.key,
463 MediaProviderClient.GroupResponse.IS_LEAF_CATEGORY.key,
464 )
465 )
466 categoriesAndAlbums.forEach { group ->
467 when (group) {
468 is Group.Album ->
469 cursor.addRow(
470 arrayOf(
471 MediaProviderClient.GroupType.ALBUM.name,
472 group.id,
473 group.pickerId.toString(),
474 group.displayName,
475 group.authority,
476 group.coverUri.toString(),
477 /* additional uri */ null,
478 /* additional uri */ null,
479 /* additional uri */ null,
480 /* category type */ null,
481 /* is leaf category */ null,
482 )
483 )
484 is Group.Category ->
485 cursor.addRow(
486 arrayOf(
487 MediaProviderClient.GroupType.CATEGORY.name,
488 group.id,
489 group.pickerId.toString(),
490 group.displayName,
491 group.authority,
492 group.icons.getOrNull(0)?.getLoadableUri()?.toString(),
493 group.icons.getOrNull(1)?.getLoadableUri()?.toString(),
494 group.icons.getOrNull(2)?.getLoadableUri()?.toString(),
495 group.icons.getOrNull(3)?.getLoadableUri()?.toString(),
496 group.categoryType.key,
497 if (group.isLeafCategory) 1 else null,
498 )
499 )
500 else -> {}
501 }
502 }
503 return cursor
504 }
505
getMediaSetsnull506 private fun getMediaSets(): Cursor {
507 val cursor =
508 MatrixCursor(
509 arrayOf(
510 MediaProviderClient.GroupResponse.GROUP_ID.key,
511 MediaProviderClient.GroupResponse.PICKER_ID.key,
512 MediaProviderClient.GroupResponse.DISPLAY_NAME.key,
513 MediaProviderClient.GroupResponse.AUTHORITY.key,
514 MediaProviderClient.GroupResponse.UNWRAPPED_COVER_URI.key,
515 )
516 )
517 mediaSets.forEach {
518 cursor.addRow(
519 arrayOf(
520 it.id,
521 it.pickerId.toString(),
522 it.displayName,
523 it.authority,
524 it.icon.getLoadableUri().toString(),
525 )
526 )
527 }
528 return cursor
529 }
530 }
531