diff options
31 files changed, 800 insertions, 155 deletions
diff --git a/apex/framework/java/android/provider/MediaStore.java b/apex/framework/java/android/provider/MediaStore.java index 19076f617..3c25ef22b 100644 --- a/apex/framework/java/android/provider/MediaStore.java +++ b/apex/framework/java/android/provider/MediaStore.java @@ -343,6 +343,9 @@ public final class MediaStore { public static final String PICKER_MEDIA_IN_MEDIA_SET_INIT_CALL = "picker_media_in_media_set_init"; /** {@hide} */ + public static final String PICKER_GET_SEARCH_PROVIDERS_CALL = + "picker_internal_get_search_providers"; + /** {@hide} */ public static final String PICKER_TRANSCODE_CALL = "picker_transcode"; /** {@hide} */ public static final String PICKER_TRANSCODE_RESULT = "picker_transcode_result"; diff --git a/photopicker/src/com/android/photopicker/data/MediaProviderClient.kt b/photopicker/src/com/android/photopicker/data/MediaProviderClient.kt index 4733523dc..014630182 100644 --- a/photopicker/src/com/android/photopicker/data/MediaProviderClient.kt +++ b/photopicker/src/com/android/photopicker/data/MediaProviderClient.kt @@ -46,8 +46,9 @@ import com.android.photopicker.features.search.model.SearchSuggestionType open class MediaProviderClient { companion object { private const val TAG = "MediaProviderClient" - private const val MEDIA_INIT_CALL_METHOD: String = "picker_media_init" - private const val SEARCH_REQUEST_INIT_CALL_METHOD = "picker_internal_search_media_init" + const val MEDIA_INIT_CALL_METHOD: String = "picker_media_init" + const val SEARCH_REQUEST_INIT_CALL_METHOD = "picker_internal_search_media_init" + const val GET_SEARCH_PROVIDERS_CALL_METHOD = "picker_internal_get_search_providers" private const val EXTRA_MIME_TYPES = "mime_types" private const val EXTRA_INTENT_ACTION = "intent_action" private const val EXTRA_PROVIDERS = "providers" @@ -56,6 +57,7 @@ open class MediaProviderClient { private const val EXTRA_ALBUM_AUTHORITY = "album_authority" private const val COLUMN_GRANTS_COUNT = "grants_count" private const val PRE_SELECTION_URIS = "pre_selection_uris" + const val SEARCH_PROVIDER_AUTHORITIES = "search_provider_authorities" const val SEARCH_REQUEST_ID = "search_request_id" } @@ -678,6 +680,43 @@ open class MediaProviderClient { } } + /** + * Get available search providers from the Media Provider client using the available + * [ContentResolver]. + * + * If the available providers are known at the time of the query, this method will filter the + * results of the call so that search providers are a subset of the available providers. + * + * @param resolver The [ContentResolver] that resolves to the desired instance of MediaProvider. + * (This may resolve in a cross profile instance of MediaProvider). + * @param availableProviders + */ + suspend fun fetchSearchProviderAuthorities( + resolver: ContentResolver, + availableProviders: List<Provider>? = null, + ): List<String>? { + try { + val availableProviderAuthorities: Set<String>? = + availableProviders?.map { it.authority }?.toSet() + val result: Bundle? = + resolver.call( + MEDIA_PROVIDER_AUTHORITY, + GET_SEARCH_PROVIDERS_CALL_METHOD, + /* arg */ null, + /* extras */ null, + ) + return result?.getStringArrayList(SEARCH_PROVIDER_AUTHORITIES)?.filter { + availableProviderAuthorities?.contains(it) ?: true + } + } catch (e: RuntimeException) { + // If we can't fetch the available providers, basic functionality of photopicker does + // not work. In order to catch this earlier in testing, throw an error instead of + // silencing it. + Log.e(TAG, "Could not fetch providers with search enabled", e) + return null + } + } + /** Creates a list of [Provider] from the given [Cursor]. */ private fun getListOfProviders(cursor: Cursor): List<Provider> { val result: MutableList<Provider> = mutableListOf<Provider>() diff --git a/photopicker/src/com/android/photopicker/data/PrefetchDataService.kt b/photopicker/src/com/android/photopicker/data/PrefetchDataService.kt index a23b139a2..a151b70b4 100644 --- a/photopicker/src/com/android/photopicker/data/PrefetchDataService.kt +++ b/photopicker/src/com/android/photopicker/data/PrefetchDataService.kt @@ -16,7 +16,7 @@ package com.android.photopicker.data -import com.android.photopicker.features.search.model.SearchEnabledState +import com.android.photopicker.features.search.model.GlobalSearchState /** Class responsible to fetch all the required data before feature initialization */ interface PrefetchDataService { @@ -24,5 +24,9 @@ interface PrefetchDataService { val TAG: String = "PrefetchDataService" } - suspend fun getSearchState(): SearchEnabledState + /** + * Get the global search state from the Data Source. The global search state refers to the + * search state of all providers in all user profiles. + */ + suspend fun getGlobalSearchState(): GlobalSearchState } diff --git a/photopicker/src/com/android/photopicker/data/PrefetchDataServiceImpl.kt b/photopicker/src/com/android/photopicker/data/PrefetchDataServiceImpl.kt index 2d2044ba5..fada902ce 100644 --- a/photopicker/src/com/android/photopicker/data/PrefetchDataServiceImpl.kt +++ b/photopicker/src/com/android/photopicker/data/PrefetchDataServiceImpl.kt @@ -16,11 +16,76 @@ package com.android.photopicker.data -import com.android.photopicker.features.search.model.SearchEnabledState +import android.content.Context +import android.util.Log +import com.android.photopicker.core.user.UserMonitor +import com.android.photopicker.core.user.UserProfile +import com.android.photopicker.features.search.model.GlobalSearchState +import com.android.photopicker.features.search.model.GlobalSearchStateInfo +import com.android.photopicker.util.mapOfDeferredWithTimeout +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Deferred -class PrefetchDataServiceImpl() : PrefetchDataService { +/** Implementation of [PrefetchDataService] that typically fetches data from MediaProvider. */ +class PrefetchDataServiceImpl( + val mediaProviderClient: MediaProviderClient, + val userMonitor: UserMonitor, + val context: Context, + val dispatcher: CoroutineDispatcher, +) : PrefetchDataService { - override suspend fun getSearchState(): SearchEnabledState { - return SearchEnabledState.DISABLED + override suspend fun getGlobalSearchState(): GlobalSearchState { + // Create a map of user id to lambda that fetches search provider authorities for that + // user. + val inputMap: Map<Int, suspend (MediaProviderClient) -> Any?> = + userMonitor.userStatus.value.allProfiles + .map { profile: UserProfile -> + val lambda: suspend (MediaProviderClient) -> Any? = + { mediaProviderClient: MediaProviderClient -> + mediaProviderClient.fetchSearchProviderAuthorities( + context + .createPackageContextAsUser( + context.packageName, /* flags */ + 0, + profile.handle, + ) + .contentResolver + ) + } + profile.identifier to lambda + } + .toMap() + + // Get a map of user id to Deferred task that fetches search provider authorities for + // that user in parallel with a timeout. + val deferredMap: Map<Int, Deferred<Any?>> = + mapOfDeferredWithTimeout( + inputMap = inputMap, + input = mediaProviderClient, + timeoutMillis = 100L, + ) + + // Await all the deferred tasks and create a map of user id to the search provider + // authorities. + @Suppress("UNCHECKED_CAST") + val globalSearchProviders: Map<Int, List<String>?> = + deferredMap + .map { + val searchProviders: Any? = it.value.await() + it.key to if (searchProviders is List<*>?) searchProviders else null + } + .toMap() as Map<Int, List<String>?> + + val globalSearchStateInfo = + GlobalSearchStateInfo( + globalSearchProviders, + userMonitor.userStatus.value.activeUserProfile.identifier, + ) + Log.d( + PrefetchDataService.TAG, + "Global search providers available are $globalSearchProviders. " + + "Search state is $globalSearchStateInfo.state", + ) + return globalSearchStateInfo.state } } diff --git a/photopicker/src/com/android/photopicker/features/search/Search.kt b/photopicker/src/com/android/photopicker/features/search/Search.kt index 400d13c83..cb6097c3f 100644 --- a/photopicker/src/com/android/photopicker/features/search/Search.kt +++ b/photopicker/src/com/android/photopicker/features/search/Search.kt @@ -104,9 +104,9 @@ import com.android.photopicker.core.selection.LocalSelection import com.android.photopicker.core.theme.LocalWindowSizeClass import com.android.photopicker.extensions.navigateToPreviewMedia import com.android.photopicker.features.preview.PreviewFeature -import com.android.photopicker.features.search.model.SearchEnabledState import com.android.photopicker.features.search.model.SearchSuggestion import com.android.photopicker.features.search.model.SearchSuggestionType +import com.android.photopicker.features.search.model.UserSearchState import com.android.photopicker.util.rememberBitmapFromUri import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -161,9 +161,9 @@ fun Search( params: LocationParams, viewModel: SearchViewModel = obtainViewModel(), ) { - val searchEnabled by viewModel.searchEnabled.collectAsStateWithLifecycle() + val userSearchStateInfo by viewModel.userSearchStateInfo.collectAsStateWithLifecycle() when { - searchEnabled == SearchEnabledState.ENABLED -> { + userSearchStateInfo.state == UserSearchState.ENABLED -> { SearchBarEnabled(params, viewModel, modifier) } else -> { diff --git a/photopicker/src/com/android/photopicker/features/search/SearchFeature.kt b/photopicker/src/com/android/photopicker/features/search/SearchFeature.kt index 26ed22801..343f5776e 100644 --- a/photopicker/src/com/android/photopicker/features/search/SearchFeature.kt +++ b/photopicker/src/com/android/photopicker/features/search/SearchFeature.kt @@ -31,7 +31,7 @@ import com.android.photopicker.core.features.PhotopickerUiFeature import com.android.photopicker.core.features.PrefetchResultKey import com.android.photopicker.core.features.Priority import com.android.photopicker.data.PrefetchDataService -import com.android.photopicker.features.search.model.SearchEnabledState +import com.android.photopicker.features.search.model.GlobalSearchState import kotlinx.coroutines.Deferred import kotlinx.coroutines.runBlocking @@ -51,7 +51,7 @@ class SearchFeature : PhotopickerUiFeature { mapOf( PrefetchResultKey.SEARCH_STATE to { prefetchDataService -> - prefetchDataService.getSearchState() + prefetchDataService.getGlobalSearchState() } ) } else { @@ -72,7 +72,9 @@ class SearchFeature : PhotopickerUiFeature { val searchStatus: Any? = deferredPrefetchResultsMap[PrefetchResultKey.SEARCH_STATE]?.await() when (searchStatus) { - is SearchEnabledState -> searchStatus == SearchEnabledState.ENABLED + is GlobalSearchState -> + searchStatus == GlobalSearchState.ENABLED || + searchStatus == GlobalSearchState.ENABLED_IN_OTHER_PROFILES_ONLY else -> false // prefetch may have timed out } } diff --git a/photopicker/src/com/android/photopicker/features/search/SearchViewModel.kt b/photopicker/src/com/android/photopicker/features/search/SearchViewModel.kt index e55bd7cd0..47f001991 100644 --- a/photopicker/src/com/android/photopicker/features/search/SearchViewModel.kt +++ b/photopicker/src/com/android/photopicker/features/search/SearchViewModel.kt @@ -35,9 +35,9 @@ import com.android.photopicker.data.model.Media import com.android.photopicker.extensions.insertMonthSeparators import com.android.photopicker.extensions.toMediaGridItemFromMedia import com.android.photopicker.features.search.data.SearchDataService -import com.android.photopicker.features.search.model.SearchEnabledState import com.android.photopicker.features.search.model.SearchSuggestion import com.android.photopicker.features.search.model.SearchSuggestionType +import com.android.photopicker.features.search.model.UserSearchStateInfo import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher @@ -109,7 +109,7 @@ constructor( * * This `StateFlow` emits updates whenever the search enabled state of a profile changes. */ - val searchEnabled: StateFlow<SearchEnabledState> = searchDataService.isSearchEnabled + val userSearchStateInfo: StateFlow<UserSearchStateInfo> = searchDataService.userSearchStateInfo private val suggestionCache = SearchSuggestionCache() diff --git a/photopicker/src/com/android/photopicker/features/search/data/FakeSearchDataServiceImpl.kt b/photopicker/src/com/android/photopicker/features/search/data/FakeSearchDataServiceImpl.kt deleted file mode 100644 index 3e6adbeea..000000000 --- a/photopicker/src/com/android/photopicker/features/search/data/FakeSearchDataServiceImpl.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.photopicker.features.search.data - -import android.net.Uri -import android.os.CancellationSignal -import androidx.paging.PagingSource -import com.android.photopicker.data.DataService -import com.android.photopicker.data.model.Media -import com.android.photopicker.data.model.MediaPageKey -import com.android.photopicker.features.search.model.SearchEnabledState -import com.android.photopicker.features.search.model.SearchSuggestion -import com.android.photopicker.features.search.model.SearchSuggestionType -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -/** - * Placeholder for the actual [SearchDataService] implementation class. This class can be used to - * unblock and test UI development till we have the actual implementation in ready. - */ -// TODO(b/361043596) Clean up once we have the implementation for [SearchDataService] class. -class FakeSearchDataServiceImpl(private val dataService: DataService) : SearchDataService { - // Use the internal flow of type StateFlow<Map<UserProfile, Boolean>> which would cache - // the result for all profiles, to populate this flow for the current profile. - override val isSearchEnabled: StateFlow<SearchEnabledState> = - MutableStateFlow(SearchEnabledState.ENABLED) - - /** Returns a few static suggestions to unblock UI development. */ - override suspend fun getSearchSuggestions( - prefix: String, - limit: Int, - cancellationSignal: CancellationSignal?, - ): List<SearchSuggestion> { - if (prefix == "testempty") { - return emptyList() - } - return listOf( - SearchSuggestion("1", "authority", "France", SearchSuggestionType.LOCATION, null), - SearchSuggestion( - "2", - "authority", - "Favorites", - SearchSuggestionType.FAVORITES_ALBUM, - Uri.parse("xyz"), - ), - SearchSuggestion( - "8", - "authority", - "Album", - SearchSuggestionType.ALBUM, - Uri.parse("xyz"), - ), - SearchSuggestion("2", "authority", "Videos", SearchSuggestionType.VIDEOS_ALBUM, null), - SearchSuggestion(null, "authority", "france", SearchSuggestionType.HISTORY, null), - SearchSuggestion(null, "authority", "paris", SearchSuggestionType.HISTORY, null), - SearchSuggestion("3", "authority", "March", SearchSuggestionType.DATE, null), - SearchSuggestion( - "3", - "authority", - "Screenshot", - SearchSuggestionType.SCREENSHOTS_ALBUM, - null, - ), - SearchSuggestion("4", "authority", "Emma", SearchSuggestionType.FACE, Uri.parse("xyz")), - SearchSuggestion("5", "authority", "Bob", SearchSuggestionType.FACE, Uri.parse("xyz")), - SearchSuggestion("6", "authority", "April", SearchSuggestionType.DATE, null), - SearchSuggestion("7", "authority", null, SearchSuggestionType.FACE, Uri.parse("xyz")), - ) - } - - /** Returns all media to unblock UI development. */ - override fun getSearchResults( - suggestion: SearchSuggestion, - cancellationSignal: CancellationSignal?, - ): PagingSource<MediaPageKey, Media> = dataService.mediaPagingSource() - - /** Returns all media to unblock UI development. */ - override fun getSearchResults( - searchText: String, - cancellationSignal: CancellationSignal?, - ): PagingSource<MediaPageKey, Media> = dataService.mediaPagingSource() -} diff --git a/photopicker/src/com/android/photopicker/features/search/data/SearchDataService.kt b/photopicker/src/com/android/photopicker/features/search/data/SearchDataService.kt index d04aac74b..9bb5691e5 100644 --- a/photopicker/src/com/android/photopicker/features/search/data/SearchDataService.kt +++ b/photopicker/src/com/android/photopicker/features/search/data/SearchDataService.kt @@ -20,8 +20,8 @@ import android.os.CancellationSignal import androidx.paging.PagingSource import com.android.photopicker.data.model.Media import com.android.photopicker.data.model.MediaPageKey -import com.android.photopicker.features.search.model.SearchEnabledState import com.android.photopicker.features.search.model.SearchSuggestion +import com.android.photopicker.features.search.model.UserSearchStateInfo import kotlinx.coroutines.flow.StateFlow /** @@ -37,11 +37,11 @@ interface SearchDataService { } /** - * A [StateFlow] that emits a value when current profile changes or search config in the data - * source changes. It hold that value of the current profile's search enabled state - * [SearchEnabledState]. + * A [StateFlow] that emits a value when current profile changes or the current profile's + * available provider changes. It hold that value of the current profile's search enabled state + * [UserSearchStateInfo]. */ - val isSearchEnabled: StateFlow<SearchEnabledState> + val userSearchStateInfo: StateFlow<UserSearchStateInfo> /** * Get search suggestions for the user in zero state and as the user is typing. diff --git a/photopicker/src/com/android/photopicker/features/search/data/SearchDataServiceImpl.kt b/photopicker/src/com/android/photopicker/features/search/data/SearchDataServiceImpl.kt index f2ad67545..c95db2064 100644 --- a/photopicker/src/com/android/photopicker/features/search/data/SearchDataServiceImpl.kt +++ b/photopicker/src/com/android/photopicker/features/search/data/SearchDataServiceImpl.kt @@ -29,14 +29,15 @@ import com.android.photopicker.data.NotificationService import com.android.photopicker.data.model.Media import com.android.photopicker.data.model.MediaPageKey import com.android.photopicker.data.model.Provider -import com.android.photopicker.features.search.model.SearchEnabledState import com.android.photopicker.features.search.model.SearchRequest import com.android.photopicker.features.search.model.SearchSuggestion +import com.android.photopicker.features.search.model.UserSearchStateInfo import java.util.concurrent.TimeoutException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -106,13 +107,17 @@ class SearchDataServiceImpl( searchResultsPagingSources.clear() searchRequestIdMap.clear() } + + _userSearchStateInfo.update { fetchSearchStateInfo() } } } } - // TODO(b/381819838) - override val isSearchEnabled: StateFlow<SearchEnabledState> = - MutableStateFlow(SearchEnabledState.ENABLED) + // Internal mutable flow of the current user's search state info. + private val _userSearchStateInfo: MutableStateFlow<UserSearchStateInfo> = + MutableStateFlow(UserSearchStateInfo(null)) + + override val userSearchStateInfo: StateFlow<UserSearchStateInfo> = _userSearchStateInfo /** * Try to get a list fo search suggestions from Media Provider in the background thread with a @@ -287,4 +292,21 @@ class SearchDataServiceImpl( } } } + + /** Get search state info for the current user. */ + private suspend fun fetchSearchStateInfo(): UserSearchStateInfo { + val contentResolver: ContentResolver = dataService.activeContentResolver.value + val searchProviderAuthorities: List<String>? = + mediaProviderClient.fetchSearchProviderAuthorities( + contentResolver, + dataService.availableProviders.value, + ) + val userSearchStateInfo = UserSearchStateInfo(searchProviderAuthorities) + Log.d( + SearchDataService.TAG, + "Available search providers for current user $searchProviderAuthorities. " + + "Search state is ${userSearchStateInfo.state}", + ) + return userSearchStateInfo + } } diff --git a/photopicker/src/com/android/photopicker/features/search/inject/SearchEmbeddedServiceModule.kt b/photopicker/src/com/android/photopicker/features/search/inject/SearchEmbeddedServiceModule.kt index 409a40c23..b50d72f19 100644 --- a/photopicker/src/com/android/photopicker/features/search/inject/SearchEmbeddedServiceModule.kt +++ b/photopicker/src/com/android/photopicker/features/search/inject/SearchEmbeddedServiceModule.kt @@ -63,6 +63,7 @@ class SearchEmbeddedServiceModule { configurationManager: ConfigurationManager, @Background scope: CoroutineScope, @Background dispatcher: CoroutineDispatcher, + mediaProviderClient: MediaProviderClient, notificationService: NotificationService, events: Events, ): SearchDataService { @@ -83,7 +84,7 @@ class SearchEmbeddedServiceModule { scope, dispatcher, notificationService, - MediaProviderClient(), + mediaProviderClient, events, ) return searchDataService diff --git a/photopicker/src/com/android/photopicker/features/search/model/SearchEnabledState.kt b/photopicker/src/com/android/photopicker/features/search/model/GlobalSearchState.kt index f969e62d3..1718a21cb 100644 --- a/photopicker/src/com/android/photopicker/features/search/model/SearchEnabledState.kt +++ b/photopicker/src/com/android/photopicker/features/search/model/GlobalSearchState.kt @@ -16,13 +16,18 @@ package com.android.photopicker.features.search.model -/** This represents the search enabled states the current profile could have. */ -enum class SearchEnabledState { +/** + * This represents valid global search states. + * + * Global search state refers to the search state of all user profiles available on the device. + */ +enum class GlobalSearchState() { /* Search is enabled for the current profile */ ENABLED, - /* Search is disabled in the current profile but enabled in other profiles */ + /* Search is disabled in the current profile but enabled in at least one of the + * other profiles */ ENABLED_IN_OTHER_PROFILES_ONLY, - /* Search is disabled in all profiles */ + /* Search is disabled in current profile and other profiles */ DISABLED, /* Either the state of the current profile is unknown, or the current profile has search * disabled and the state of other profile(s) is unknown. */ diff --git a/photopicker/src/com/android/photopicker/features/search/model/GlobalSearchStateInfo.kt b/photopicker/src/com/android/photopicker/features/search/model/GlobalSearchStateInfo.kt new file mode 100644 index 000000000..eb3326384 --- /dev/null +++ b/photopicker/src/com/android/photopicker/features/search/model/GlobalSearchStateInfo.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.photopicker.features.search.model + +/** Holds search state info for all user profiles on the device. */ +data class GlobalSearchStateInfo( + // Map of all available profiles to the provider authorities that have search feature + // enabled. If no providers have search enabled, tha value in map should be an empty string. + // If the information is unknown for a given profile, the value in map should be null. + val providersWithSearchEnabled: Map<Int, List<String>?>, + val currentUserId: Int, +) { + val state: GlobalSearchState = + when { + // Check if search is enabled in current profile + providersWithSearchEnabled[currentUserId]?.isNotEmpty() ?: false -> + GlobalSearchState.ENABLED + + // Check if search is enabled in any other profile + providersWithSearchEnabled.values.any { providers -> + providers?.isNotEmpty() ?: false + } -> GlobalSearchState.ENABLED_IN_OTHER_PROFILES_ONLY + + // Check if there is missing information + providersWithSearchEnabled.values.any { providers -> providers == null } -> + GlobalSearchState.UNKNOWN + + // If we have all information and search is not enabled in any profile, + // search is disabled. + else -> GlobalSearchState.DISABLED + } +} diff --git a/photopicker/src/com/android/photopicker/features/search/model/UserSearchState.kt b/photopicker/src/com/android/photopicker/features/search/model/UserSearchState.kt new file mode 100644 index 000000000..c9bfaf3d2 --- /dev/null +++ b/photopicker/src/com/android/photopicker/features/search/model/UserSearchState.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.photopicker.features.search.model + +/** + * This represents valid user search states. + * + * User search state refers to the search state of the current selected profile in a Picker session. + */ +enum class UserSearchState() { + /* Search is enabled in the current profile */ + ENABLED, + /* Search is disabled in the current profile */ + DISABLED, + /* Search state for the current profile is unknown */ + UNKNOWN, +} diff --git a/photopicker/src/com/android/photopicker/features/search/model/UserSearchStateInfo.kt b/photopicker/src/com/android/photopicker/features/search/model/UserSearchStateInfo.kt new file mode 100644 index 000000000..885cbf218 --- /dev/null +++ b/photopicker/src/com/android/photopicker/features/search/model/UserSearchStateInfo.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.photopicker.features.search.model + +/** Holds search state info for the current selected profile in a Picker session. */ +data class UserSearchStateInfo(val searchProviderAuthorities: List<String>?) { + val state: UserSearchState = + when { + searchProviderAuthorities == null -> UserSearchState.UNKNOWN + searchProviderAuthorities.isEmpty() -> UserSearchState.DISABLED + else -> UserSearchState.ENABLED + } +} diff --git a/photopicker/src/com/android/photopicker/inject/ActivityModule.kt b/photopicker/src/com/android/photopicker/inject/ActivityModule.kt index dc69be86a..d95d30733 100644 --- a/photopicker/src/com/android/photopicker/inject/ActivityModule.kt +++ b/photopicker/src/com/android/photopicker/inject/ActivityModule.kt @@ -194,6 +194,7 @@ class ActivityModule { @Background scope: CoroutineScope, @Background dispatcher: CoroutineDispatcher, userMonitor: UserMonitor, + mediaProviderClient: MediaProviderClient, notificationService: NotificationService, configurationManager: ConfigurationManager, featureManager: FeatureManager, @@ -212,7 +213,7 @@ class ActivityModule { scope, dispatcher, notificationService, - MediaProviderClient(), + mediaProviderClient, configurationManager.configuration, featureManager, appContext, @@ -311,7 +312,12 @@ class ActivityModule { @Provides @ActivityRetainedScoped - fun providePrefetchDataService(): PrefetchDataService { + fun providePrefetchDataService( + userMonitor: UserMonitor, + @ApplicationContext context: Context, + @Background backgroundDispatcher: CoroutineDispatcher, + mediaProviderClient: MediaProviderClient, + ): PrefetchDataService { if (!::prefetchDataService.isInitialized) { Log.d( @@ -319,7 +325,13 @@ class ActivityModule { "PrefetchDataService requested but not yet initialized. " + "Initializing PrefetchDataService.", ) - prefetchDataService = PrefetchDataServiceImpl() + prefetchDataService = + PrefetchDataServiceImpl( + mediaProviderClient, + userMonitor, + context, + backgroundDispatcher, + ) } return prefetchDataService } diff --git a/photopicker/src/com/android/photopicker/inject/ApplicationModule.kt b/photopicker/src/com/android/photopicker/inject/ApplicationModule.kt index cdadb5295..f9f3a45d8 100644 --- a/photopicker/src/com/android/photopicker/inject/ApplicationModule.kt +++ b/photopicker/src/com/android/photopicker/inject/ApplicationModule.kt @@ -22,6 +22,7 @@ import android.util.Log import com.android.photopicker.core.configuration.DeviceConfigProxy import com.android.photopicker.core.configuration.DeviceConfigProxyImpl import com.android.photopicker.core.network.NetworkMonitor +import com.android.photopicker.data.MediaProviderClient import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -78,6 +79,12 @@ class ApplicationModule { return DeviceConfigProxyImpl() } + /** Provider for [MediaProviderClient]. */ + @Provides + fun providerMediaProviderClient(): MediaProviderClient { + return MediaProviderClient() + } + /** * Provider for the [NetworkMonitor]. This is lazily initialized only when requested to save on * initialization costs of this module. @@ -93,7 +100,7 @@ class ApplicationModule { } else { Log.d( NetworkMonitor.TAG, - "NetworkMonitor requested, but not yet initialized. Initializing NetworkMonitor." + "NetworkMonitor requested, but not yet initialized. Initializing NetworkMonitor.", ) networkMonitor = NetworkMonitor(context, scope) return networkMonitor diff --git a/photopicker/src/com/android/photopicker/inject/EmbeddedServiceModule.kt b/photopicker/src/com/android/photopicker/inject/EmbeddedServiceModule.kt index fd87f4a86..8821d56bc 100644 --- a/photopicker/src/com/android/photopicker/inject/EmbeddedServiceModule.kt +++ b/photopicker/src/com/android/photopicker/inject/EmbeddedServiceModule.kt @@ -254,6 +254,7 @@ class EmbeddedServiceModule { @ApplicationContext appContext: Context, events: Events, processOwnerHandle: UserHandle, + mediaProviderClient: MediaProviderClient, ): DataService { if (!::dataService.isInitialized) { @@ -267,7 +268,7 @@ class EmbeddedServiceModule { scope, dispatcher, notificationService, - MediaProviderClient(), + mediaProviderClient, configurationManager.configuration, featureManager, appContext, @@ -389,7 +390,12 @@ class EmbeddedServiceModule { @Provides @SessionScoped - fun providePrefetchDataService(): PrefetchDataService { + fun providePrefetchDataService( + userMonitor: UserMonitor, + @ApplicationContext context: Context, + @Background backgroundDispatcher: CoroutineDispatcher, + mediaProviderClient: MediaProviderClient, + ): PrefetchDataService { if (!::prefetchDataService.isInitialized) { Log.d( @@ -397,7 +403,13 @@ class EmbeddedServiceModule { "PrefetchDataService requested but not yet initialized. " + "Initializing PrefetchDataService.", ) - prefetchDataService = PrefetchDataServiceImpl() + prefetchDataService = + PrefetchDataServiceImpl( + mediaProviderClient, + userMonitor, + context, + backgroundDispatcher, + ) } return prefetchDataService } diff --git a/photopicker/src/com/android/photopicker/util/MapOfDeferredWithTimeout.kt b/photopicker/src/com/android/photopicker/util/MapOfDeferredWithTimeout.kt index 776e59f60..11e992272 100644 --- a/photopicker/src/com/android/photopicker/util/MapOfDeferredWithTimeout.kt +++ b/photopicker/src/com/android/photopicker/util/MapOfDeferredWithTimeout.kt @@ -52,7 +52,7 @@ suspend fun <A, B> mapOfDeferredWithTimeout( result } } catch (e: RuntimeException) { - Log.e(TAG, "An error occurred in fetching result for key: $key") + Log.e(TAG, "An error occurred in fetching result for key: $key", e) null } } diff --git a/photopicker/tests/src/com/android/photopicker/data/DataServiceImplTest.kt b/photopicker/tests/src/com/android/photopicker/data/DataServiceImplTest.kt index d74cc8f7e..49540e5e4 100644 --- a/photopicker/tests/src/com/android/photopicker/data/DataServiceImplTest.kt +++ b/photopicker/tests/src/com/android/photopicker/data/DataServiceImplTest.kt @@ -76,8 +76,6 @@ import org.mockito.Mockito.verify @OptIn(ExperimentalCoroutinesApi::class) class DataServiceImplTest { - val testSessionId = generatePickerSessionId() - companion object { private fun createUserHandle(userId: Int = 0): UserHandle { val parcel = Parcel.obtain() @@ -191,7 +189,7 @@ class DataServiceImplTest { defaultConfiguration = PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, @@ -219,7 +217,7 @@ class DataServiceImplTest { defaultConfiguration = PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, @@ -358,7 +356,7 @@ class DataServiceImplTest { defaultConfiguration = PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, @@ -385,7 +383,7 @@ class DataServiceImplTest { this.backgroundScope, PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, @@ -1052,7 +1050,7 @@ class DataServiceImplTest { defaultConfiguration = PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, @@ -1080,7 +1078,7 @@ class DataServiceImplTest { defaultConfiguration = PhotopickerConfiguration( action = "TEST_ACTION", - sessionId = testSessionId, + sessionId = sessionId, flags = PhotopickerFlags( CLOUD_MEDIA_ENABLED = true, diff --git a/photopicker/tests/src/com/android/photopicker/data/PrefetchDataServiceImplTest.kt b/photopicker/tests/src/com/android/photopicker/data/PrefetchDataServiceImplTest.kt new file mode 100644 index 000000000..02d1ceddf --- /dev/null +++ b/photopicker/tests/src/com/android/photopicker/data/PrefetchDataServiceImplTest.kt @@ -0,0 +1,361 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package src.com.android.photopicker.data + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.content.pm.UserProperties +import android.os.Parcel +import android.os.UserHandle +import android.os.UserManager +import android.provider.MediaStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.modules.utils.build.SdkLevel +import com.android.photopicker.R +import com.android.photopicker.core.configuration.TestPhotopickerConfiguration +import com.android.photopicker.core.configuration.provideTestConfigurationFlow +import com.android.photopicker.core.user.UserMonitor +import com.android.photopicker.core.user.UserProfile +import com.android.photopicker.data.MediaProviderClient +import com.android.photopicker.data.PrefetchDataServiceImpl +import com.android.photopicker.data.TestMediaProvider +import com.android.photopicker.features.search.model.GlobalSearchState +import com.android.photopicker.util.test.mockSystemService +import com.android.photopicker.util.test.nonNullableEq +import com.android.photopicker.util.test.whenever +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.any +import org.mockito.Mockito.anyInt +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +public class PrefetchDataServiceImplTest { + @Mock private lateinit var mockPrimaryUserContext: Context + @Mock private lateinit var mockManagedUserContext: Context + @Mock private lateinit var mockUserManager: UserManager + @Mock private lateinit var mockPackageManager: PackageManager + @Mock private lateinit var mockResolveInfo: ResolveInfo + + private lateinit var testPrimaryUserContentProvider: TestMediaProvider + private lateinit var testManagedUserContentProvider: TestMediaProvider + private lateinit var testPrimaryUserContentResolver: ContentResolver + private lateinit var testManagedUserContentResolver: ContentResolver + + private val PLATFORM_PROVIDED_PROFILE_LABEL = "Platform Label" + + private val USER_HANDLE_PRIMARY: UserHandle + private val USER_ID_PRIMARY: Int = 0 + private val PRIMARY_PROFILE_BASE: UserProfile + + private val USER_HANDLE_MANAGED: UserHandle + private val USER_ID_MANAGED: Int = 10 + private val MANAGED_PROFILE_BASE: UserProfile + + init { + val parcel1 = Parcel.obtain() + parcel1.writeInt(USER_ID_PRIMARY) + parcel1.setDataPosition(0) + USER_HANDLE_PRIMARY = UserHandle(parcel1) + parcel1.recycle() + + PRIMARY_PROFILE_BASE = + UserProfile( + handle = USER_HANDLE_PRIMARY, + profileType = UserProfile.ProfileType.PRIMARY, + label = PLATFORM_PROVIDED_PROFILE_LABEL, + ) + + val parcel2 = Parcel.obtain() + parcel2.writeInt(USER_ID_MANAGED) + parcel2.setDataPosition(0) + USER_HANDLE_MANAGED = UserHandle(parcel2) + parcel2.recycle() + + MANAGED_PROFILE_BASE = + UserProfile( + handle = USER_HANDLE_MANAGED, + profileType = UserProfile.ProfileType.MANAGED, + label = PLATFORM_PROVIDED_PROFILE_LABEL, + ) + } + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + val resources = InstrumentationRegistry.getInstrumentation().getContext().getResources() + + testPrimaryUserContentProvider = TestMediaProvider() + testPrimaryUserContentResolver = ContentResolver.wrap(testPrimaryUserContentProvider) + testManagedUserContentProvider = TestMediaProvider() + testManagedUserContentResolver = ContentResolver.wrap(testManagedUserContentProvider) + + mockSystemService(mockPrimaryUserContext, UserManager::class.java) { mockUserManager } + mockSystemService(mockManagedUserContext, UserManager::class.java) { mockUserManager } + + whenever(mockPrimaryUserContext.packageManager) { mockPackageManager } + whenever(mockPrimaryUserContext.contentResolver) { testPrimaryUserContentResolver } + whenever(mockManagedUserContext.contentResolver) { testManagedUserContentResolver } + whenever( + mockPrimaryUserContext.createPackageContextAsUser( + any(), + anyInt(), + nonNullableEq(USER_HANDLE_PRIMARY), + ) + ) { + mockPrimaryUserContext + } + whenever( + mockPrimaryUserContext.createPackageContextAsUser( + any(), + anyInt(), + nonNullableEq(USER_HANDLE_MANAGED), + ) + ) { + mockManagedUserContext + } + whenever( + mockManagedUserContext.createPackageContextAsUser( + any(), + anyInt(), + nonNullableEq(USER_HANDLE_PRIMARY), + ) + ) { + mockPrimaryUserContext + } + whenever( + mockManagedUserContext.createPackageContextAsUser( + any(), + anyInt(), + nonNullableEq(USER_HANDLE_MANAGED), + ) + ) { + mockManagedUserContext + } + whenever( + mockPrimaryUserContext.createContextAsUser(nonNullableEq(USER_HANDLE_PRIMARY), anyInt()) + ) { + mockPrimaryUserContext + } + whenever( + mockPrimaryUserContext.createContextAsUser(nonNullableEq(USER_HANDLE_MANAGED), anyInt()) + ) { + mockManagedUserContext + } + whenever( + mockManagedUserContext.createContextAsUser(nonNullableEq(USER_HANDLE_PRIMARY), anyInt()) + ) { + mockPrimaryUserContext + } + whenever( + mockManagedUserContext.createContextAsUser(nonNullableEq(USER_HANDLE_MANAGED), anyInt()) + ) { + mockManagedUserContext + } + + // Initial setup state: Two profiles (Personal/Work), both enabled + whenever(mockUserManager.userProfiles) { listOf(USER_HANDLE_PRIMARY, USER_HANDLE_MANAGED) } + + // Default responses for relevant UserManager apis + whenever(mockUserManager.isQuietModeEnabled(USER_HANDLE_PRIMARY)) { false } + whenever(mockUserManager.isManagedProfile(USER_ID_PRIMARY)) { false } + whenever(mockUserManager.isQuietModeEnabled(USER_HANDLE_MANAGED)) { false } + whenever(mockUserManager.isManagedProfile(USER_ID_MANAGED)) { true } + whenever(mockUserManager.getProfileParent(USER_HANDLE_MANAGED)) { USER_HANDLE_PRIMARY } + + whenever(mockResolveInfo.isCrossProfileIntentForwarderActivity()) { true } + whenever(mockPackageManager.queryIntentActivities(any(Intent::class.java), anyInt())) { + listOf(mockResolveInfo) + } + + if (SdkLevel.isAtLeastV()) { + whenever(mockUserManager.getUserBadge()) { + resources.getDrawable(R.drawable.android, /* theme= */ null) + } + whenever(mockUserManager.getProfileLabel()) { PLATFORM_PROVIDED_PROFILE_LABEL } + whenever(mockUserManager.getUserProperties(USER_HANDLE_PRIMARY)) { + UserProperties.Builder().build() + } + // By default, allow managed profile to be available + whenever(mockUserManager.getUserProperties(USER_HANDLE_MANAGED)) { + UserProperties.Builder() + .setCrossProfileContentSharingStrategy( + UserProperties.CROSS_PROFILE_CONTENT_SHARING_DELEGATE_FROM_PARENT + ) + .build() + } + } + } + + @Test + fun testGetGlobalSearchStateEnabled() = runTest { + testManagedUserContentProvider.searchProviders = listOf() + + val userMonitor = + UserMonitor( + mockPrimaryUserContext, + provideTestConfigurationFlow( + scope = this.backgroundScope, + defaultConfiguration = + TestPhotopickerConfiguration.build { + action(MediaStore.ACTION_PICK_IMAGES) + intent(Intent(MediaStore.ACTION_PICK_IMAGES)) + }, + ), + this.backgroundScope, + StandardTestDispatcher(this.testScheduler), + USER_HANDLE_PRIMARY, + ) + + val prefetchDataService = + PrefetchDataServiceImpl( + MediaProviderClient(), + userMonitor, + mockPrimaryUserContext, + StandardTestDispatcher(this.testScheduler), + ) + + val globalSearchState = prefetchDataService.getGlobalSearchState() + + assertWithMessage("Global search state is not enabled") + .that(globalSearchState) + .isEqualTo(GlobalSearchState.ENABLED) + } + + @Test + fun testGetGlobalSearchStateEnabledInOtherProfiles() = runTest { + val userMonitor = + UserMonitor( + mockPrimaryUserContext, + provideTestConfigurationFlow( + scope = this.backgroundScope, + defaultConfiguration = + TestPhotopickerConfiguration.build { + action(MediaStore.ACTION_PICK_IMAGES) + intent(Intent(MediaStore.ACTION_PICK_IMAGES)) + }, + ), + this.backgroundScope, + StandardTestDispatcher(this.testScheduler), + USER_HANDLE_PRIMARY, + ) + + val prefetchDataService = + PrefetchDataServiceImpl( + MediaProviderClient(), + userMonitor, + mockPrimaryUserContext, + StandardTestDispatcher(this.testScheduler), + ) + + testPrimaryUserContentProvider.searchProviders = listOf() + val globalSearchState1 = prefetchDataService.getGlobalSearchState() + assertWithMessage("Global search state is not enabled in other profiles") + .that(globalSearchState1) + .isEqualTo(GlobalSearchState.ENABLED_IN_OTHER_PROFILES_ONLY) + + testPrimaryUserContentProvider.searchProviders = null + val globalSearchState2 = prefetchDataService.getGlobalSearchState() + assertWithMessage("Global search state is not enabled in other profiles") + .that(globalSearchState2) + .isEqualTo(GlobalSearchState.ENABLED_IN_OTHER_PROFILES_ONLY) + } + + @Test + fun testGetGlobalSearchStateUnknown() = runTest { + val userMonitor = + UserMonitor( + mockPrimaryUserContext, + provideTestConfigurationFlow( + scope = this.backgroundScope, + defaultConfiguration = + TestPhotopickerConfiguration.build { + action(MediaStore.ACTION_PICK_IMAGES) + intent(Intent(MediaStore.ACTION_PICK_IMAGES)) + }, + ), + this.backgroundScope, + StandardTestDispatcher(this.testScheduler), + USER_HANDLE_PRIMARY, + ) + + val prefetchDataService = + PrefetchDataServiceImpl( + MediaProviderClient(), + userMonitor, + mockPrimaryUserContext, + StandardTestDispatcher(this.testScheduler), + ) + + testPrimaryUserContentProvider.searchProviders = listOf() + testManagedUserContentProvider.searchProviders = null + val globalSearchState = prefetchDataService.getGlobalSearchState() + + assertWithMessage("Global search state is not enabled in other profiles") + .that(globalSearchState) + .isEqualTo(GlobalSearchState.UNKNOWN) + } + + @Test + fun testGetGlobalSearchStateDisabled() = runTest { + testPrimaryUserContentProvider.searchProviders = listOf() + testManagedUserContentProvider.searchProviders = listOf() + + val userMonitor = + UserMonitor( + mockPrimaryUserContext, + provideTestConfigurationFlow( + scope = this.backgroundScope, + defaultConfiguration = + TestPhotopickerConfiguration.build { + action(MediaStore.ACTION_PICK_IMAGES) + intent(Intent(MediaStore.ACTION_PICK_IMAGES)) + }, + ), + this.backgroundScope, + StandardTestDispatcher(this.testScheduler), + USER_HANDLE_PRIMARY, + ) + + val prefetchDataService = + PrefetchDataServiceImpl( + MediaProviderClient(), + userMonitor, + mockPrimaryUserContext, + StandardTestDispatcher(this.testScheduler), + ) + + val globalSearchState = prefetchDataService.getGlobalSearchState() + + assertWithMessage("Global search state is not enabled in other profiles") + .that(globalSearchState) + .isEqualTo(GlobalSearchState.DISABLED) + } +} diff --git a/photopicker/tests/src/com/android/photopicker/data/TestMediaProvider.kt b/photopicker/tests/src/com/android/photopicker/data/TestMediaProvider.kt index cab01bcc8..f5bef5275 100644 --- a/photopicker/tests/src/com/android/photopicker/data/TestMediaProvider.kt +++ b/photopicker/tests/src/com/android/photopicker/data/TestMediaProvider.kt @@ -138,6 +138,7 @@ class TestMediaProvider( var albumMedia: Map<String, List<Media>> = DEFAULT_ALBUM_MEDIA, var searchRequestId: Int = DEFAULT_SEARCH_REQUEST_ID, var searchSuggestions: List<SearchSuggestion> = DEFAULT_SEARCH_SUGGESTIONS, + var searchProviders: List<Provider>? = DEFAULT_PROVIDERS, ) : MockContentProvider() { var lastRefreshMediaRequest: Bundle? = null var TEST_GRANTS_COUNT = 2 @@ -173,13 +174,22 @@ class TestMediaProvider( override fun call(authority: String, method: String, arg: String?, extras: Bundle?): Bundle? { return when (method) { - "picker_media_init" -> { + MediaProviderClient.MEDIA_INIT_CALL_METHOD -> { initMedia(extras) null } - "picker_internal_search_media_init" -> { + MediaProviderClient.SEARCH_REQUEST_INIT_CALL_METHOD -> { bundleOf(MediaProviderClient.SEARCH_REQUEST_ID to searchRequestId) } + MediaProviderClient.GET_SEARCH_PROVIDERS_CALL_METHOD -> + bundleOf( + MediaProviderClient.SEARCH_PROVIDER_AUTHORITIES to + if (searchProviders == null) null + else + arrayListOf<String>().apply { + searchProviders?.map { it.authority }?.toCollection(this) + } + ) else -> throw UnsupportedOperationException("Could not recognize method $method") } } diff --git a/photopicker/tests/src/com/android/photopicker/data/TestPrefetchDataService.kt b/photopicker/tests/src/com/android/photopicker/data/TestPrefetchDataService.kt index c66d25635..a9c377cc5 100644 --- a/photopicker/tests/src/com/android/photopicker/data/TestPrefetchDataService.kt +++ b/photopicker/tests/src/com/android/photopicker/data/TestPrefetchDataService.kt @@ -16,12 +16,12 @@ package com.android.photopicker.data -import com.android.photopicker.features.search.model.SearchEnabledState +import com.android.photopicker.features.search.model.GlobalSearchState class TestPrefetchDataService() : PrefetchDataService { - var searchEnabledState = SearchEnabledState.ENABLED + var globalSearchState = GlobalSearchState.ENABLED - override suspend fun getSearchState(): SearchEnabledState { - return searchEnabledState + override suspend fun getGlobalSearchState(): GlobalSearchState { + return globalSearchState } } diff --git a/photopicker/tests/src/com/android/photopicker/data/paging/MediaProviderClientTest.kt b/photopicker/tests/src/com/android/photopicker/data/paging/MediaProviderClientTest.kt index cfc7b50e5..fcd3db580 100644 --- a/photopicker/tests/src/com/android/photopicker/data/paging/MediaProviderClientTest.kt +++ b/photopicker/tests/src/com/android/photopicker/data/paging/MediaProviderClientTest.kt @@ -27,6 +27,7 @@ import androidx.test.filters.SmallTest import com.android.photopicker.core.configuration.PhotopickerConfiguration import com.android.photopicker.core.configuration.TestPhotopickerConfiguration import com.android.photopicker.core.events.generatePickerSessionId +import com.android.photopicker.data.DEFAULT_PROVIDERS import com.android.photopicker.data.DEFAULT_SEARCH_REQUEST_ID import com.android.photopicker.data.DEFAULT_SEARCH_SUGGESTIONS import com.android.photopicker.data.MediaProviderClient @@ -487,4 +488,45 @@ class MediaProviderClientTest { assertThat(searchSuggestions[index]).isEqualTo(DEFAULT_SEARCH_SUGGESTIONS[index]) } } + + @Test + fun testFetchSearchProvidersWithAvailableProvidersKnown() = runTest { + val mediaProviderClient = MediaProviderClient() + val localProvider = + Provider( + authority = "local_authority", + mediaSource = MediaSource.LOCAL, + uid = 0, + displayName = "", + ) + val cloudProvider = + Provider( + authority = "cloud_authority", + mediaSource = MediaSource.REMOTE, + uid = 0, + displayName = "", + ) + val testContentProvider: TestMediaProvider = + TestMediaProvider(searchProviders = listOf(localProvider, cloudProvider)) + val testContentResolver: ContentResolver = ContentResolver.wrap(testContentProvider) + + val searchProviderAuthorities = + mediaProviderClient.fetchSearchProviderAuthorities( + resolver = testContentResolver, + availableProviders = listOf(localProvider), + ) + + assertThat(searchProviderAuthorities).isEqualTo(listOf(localProvider.authority)) + } + + @Test + fun testFetchSearchProviders() = runTest { + val mediaProviderClient = MediaProviderClient() + val testContentResolver: ContentResolver = ContentResolver.wrap(testContentProvider) + val searchProviderAuthorities = + mediaProviderClient.fetchSearchProviderAuthorities(resolver = testContentResolver) + + assertThat(searchProviderAuthorities) + .isEqualTo(DEFAULT_PROVIDERS.map { it.authority }.toList()) + } } diff --git a/photopicker/tests/src/com/android/photopicker/features/search/SearchFeatureTest.kt b/photopicker/tests/src/com/android/photopicker/features/search/SearchFeatureTest.kt index a0aa0fdba..4454646aa 100644 --- a/photopicker/tests/src/com/android/photopicker/features/search/SearchFeatureTest.kt +++ b/photopicker/tests/src/com/android/photopicker/features/search/SearchFeatureTest.kt @@ -59,7 +59,7 @@ import com.android.photopicker.core.features.PrefetchResultKey import com.android.photopicker.core.selection.Selection import com.android.photopicker.data.model.Media import com.android.photopicker.features.PhotopickerFeatureBaseTest -import com.android.photopicker.features.search.model.SearchEnabledState +import com.android.photopicker.features.search.model.GlobalSearchState import com.android.photopicker.inject.PhotopickerTestModule import com.android.photopicker.tests.HiltTestActivity import com.android.providers.media.flags.Flags @@ -139,7 +139,7 @@ class SearchFeatureTest : PhotopickerFeatureBaseTest() { PrefetchResultKey.SEARCH_STATE to runBlocking { async { - return@async SearchEnabledState.ENABLED + return@async GlobalSearchState.ENABLED } } ) diff --git a/photopicker/tests/src/com/android/photopicker/features/search/TestSearchDataServiceImpl.kt b/photopicker/tests/src/com/android/photopicker/features/search/TestSearchDataServiceImpl.kt index f89933d7f..7254541ed 100644 --- a/photopicker/tests/src/com/android/photopicker/features/search/TestSearchDataServiceImpl.kt +++ b/photopicker/tests/src/com/android/photopicker/features/search/TestSearchDataServiceImpl.kt @@ -23,9 +23,9 @@ import com.android.photopicker.data.model.Media import com.android.photopicker.data.model.MediaPageKey import com.android.photopicker.data.paging.FakeInMemoryMediaPagingSource import com.android.photopicker.features.search.data.SearchDataService -import com.android.photopicker.features.search.model.SearchEnabledState import com.android.photopicker.features.search.model.SearchSuggestion import com.android.photopicker.features.search.model.SearchSuggestionType +import com.android.photopicker.features.search.model.UserSearchStateInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -37,8 +37,8 @@ class TestSearchDataServiceImpl() : SearchDataService { var mediaSetSize: Int = FakeInMemoryMediaPagingSource.DEFAULT_SIZE var mediaList: List<Media>? = null - override val isSearchEnabled: StateFlow<SearchEnabledState> = - MutableStateFlow(SearchEnabledState.ENABLED) + override val userSearchStateInfo: StateFlow<UserSearchStateInfo> = + MutableStateFlow(UserSearchStateInfo(listOf("test_provider"))) override suspend fun getSearchSuggestions( prefix: String, diff --git a/photopicker/tests/src/com/android/photopicker/inject/PhotopickerTestModule.kt b/photopicker/tests/src/com/android/photopicker/inject/PhotopickerTestModule.kt index 264f09021..411934ffb 100644 --- a/photopicker/tests/src/com/android/photopicker/inject/PhotopickerTestModule.kt +++ b/photopicker/tests/src/com/android/photopicker/inject/PhotopickerTestModule.kt @@ -39,6 +39,7 @@ import com.android.photopicker.core.selection.SelectionStrategy import com.android.photopicker.core.selection.SelectionStrategy.Companion.determineSelectionStrategy import com.android.photopicker.core.user.UserMonitor import com.android.photopicker.data.DataService +import com.android.photopicker.data.MediaProviderClient import com.android.photopicker.data.PrefetchDataService import com.android.photopicker.data.TestDataServiceImpl import com.android.photopicker.data.TestPrefetchDataService @@ -233,6 +234,12 @@ abstract class PhotopickerTestModule(val options: TestOptions = TestOptions.Buil @Singleton @Provides + fun createMediaProviderClient(): MediaProviderClient { + return MediaProviderClient() + } + + @Singleton + @Provides fun createPrefetchDataService(): PrefetchDataService { return TestPrefetchDataService() } diff --git a/src/com/android/providers/media/MediaProvider.java b/src/com/android/providers/media/MediaProvider.java index 3e60f7c9b..6867319f4 100644 --- a/src/com/android/providers/media/MediaProvider.java +++ b/src/com/android/providers/media/MediaProvider.java @@ -7108,6 +7108,9 @@ public class MediaProvider extends ContentProvider { initMediaSets(extras); return new Bundle(); } + case MediaStore.PICKER_GET_SEARCH_PROVIDERS_CALL: { + return getPickerSearchProviders(); + } case MediaStore.PICKER_TRANSCODE_CALL: { return getResultForPickerTranscode(extras); } @@ -7654,6 +7657,18 @@ public class MediaProvider extends ContentProvider { PickerDataLayerV2.triggerMediaSyncForMediaSet(extras, getContext()); } + @Nullable + private Bundle getPickerSearchProviders() { + Log.i(TAG, "Received picker internal call to get available search providers."); + if (!checkPermissionShell(Binder.getCallingUid()) + && !checkPermissionSelf(Binder.getCallingUid()) + && !isCallerPhotoPicker()) { + throw new SecurityException( + getSecurityExceptionMessage("Picker get search providers")); + } + return PickerDataLayerV2.getSearchProviders(getContext()); + } + /** * Checks if the caller has the permission to handle picker search media init. If not, * this method throws a security exception. diff --git a/src/com/android/providers/media/photopicker/SearchState.java b/src/com/android/providers/media/photopicker/SearchState.java index e6cbeabf3..44bf433ad 100644 --- a/src/com/android/providers/media/photopicker/SearchState.java +++ b/src/com/android/providers/media/photopicker/SearchState.java @@ -111,7 +111,7 @@ public class SearchState { } if (!Flags.enablePhotopickerSearch()) { - Log.d(TAG, "Search feature is disabled."); + Log.d(TAG, "Search feature flag is disabled."); return false; } diff --git a/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2.java b/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2.java index 2712b5759..ca919e280 100644 --- a/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2.java +++ b/src/com/android/providers/media/photopicker/v2/PickerDataLayerV2.java @@ -1439,6 +1439,36 @@ public class PickerDataLayerV2 { } /** + * @param context the application context. + * @return a bundle with the list of available provider authorities that support the + * search feature. If no providers are available, return an empty list in the bundle. + */ + @NonNull + public static Bundle getSearchProviders(@NonNull Context context) { + Log.d(TAG, "Calculating available search providers."); + + requireNonNull(context); + + // Check the state of cloud and local search. + final PickerSyncController syncController = PickerSyncController.getInstanceOrThrow(); + final String cloudProvider = syncController.getCloudProviderOrDefault(null); + final boolean isCloudSearchEnabled = + syncController.getSearchState().isCloudSearchEnabled(context, cloudProvider); + final boolean isLocalSearchEnabled = syncController.getSearchState().isLocalSearchEnabled(); + + // Prepare a bundle response with the result. + final ArrayList<String> searchProviderAuthorities = new ArrayList<>(); + if (isCloudSearchEnabled) searchProviderAuthorities.add(cloudProvider); + if (isLocalSearchEnabled) searchProviderAuthorities.add(syncController.getLocalProvider()); + + final Bundle result = new Bundle(); + result.putStringArrayList( + PickerSQLConstants.EXTRA_SEARCH_PROVIDER_AUTHORITIES, searchProviderAuthorities); + Log.d(TAG, "Available search providers are: " + result); + return result; + } + + /** * Schedules MediaSets sync for both local and cloud provider if the corresponding * providers implement Categories. * @param appContext The application context @@ -1598,7 +1628,7 @@ public class PickerDataLayerV2 { @NonNull private static Bundle getSearchRequestInitResponse(int searchRequestId) { final Bundle response = new Bundle(); - response.putInt("search_request_id", searchRequestId); + response.putInt(PickerSQLConstants.EXTRA_SEARCH_REQUEST_ID, searchRequestId); return response; } } diff --git a/src/com/android/providers/media/photopicker/v2/sqlite/PickerSQLConstants.java b/src/com/android/providers/media/photopicker/v2/sqlite/PickerSQLConstants.java index 750d3851a..8e5f2bffc 100644 --- a/src/com/android/providers/media/photopicker/v2/sqlite/PickerSQLConstants.java +++ b/src/com/android/providers/media/photopicker/v2/sqlite/PickerSQLConstants.java @@ -42,6 +42,8 @@ import java.util.Objects; public class PickerSQLConstants { public static final int DEFAULT_SEARCH_SUGGESTIONS_LIMIT = 50; public static final int DEFAULT_SEARCH_HISTORY_SUGGESTIONS_LIMIT = 3; + public static String EXTRA_SEARCH_REQUEST_ID = "search_request_id"; + public static String EXTRA_SEARCH_PROVIDER_AUTHORITIES = "search_provider_authorities"; static final String COUNT_COLUMN = "Count"; /** |