1 /* 2 * Copyright 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.features.cloudmedia 18 19 import android.content.Intent 20 import android.provider.MediaStore 21 import androidx.compose.runtime.Composable 22 import androidx.compose.runtime.rememberCoroutineScope 23 import androidx.compose.ui.Modifier 24 import androidx.compose.ui.platform.LocalContext 25 import androidx.compose.ui.res.stringResource 26 import com.android.photopicker.R 27 import com.android.photopicker.core.banners.Banner 28 import com.android.photopicker.core.banners.BannerDefinitions 29 import com.android.photopicker.core.banners.BannerState 30 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration 31 import com.android.photopicker.core.configuration.PhotopickerConfiguration 32 import com.android.photopicker.core.configuration.PhotopickerRuntimeEnv 33 import com.android.photopicker.core.events.Event 34 import com.android.photopicker.core.events.LocalEvents 35 import com.android.photopicker.core.events.RegisteredEventClass 36 import com.android.photopicker.core.events.Telemetry 37 import com.android.photopicker.core.features.FeatureManager 38 import com.android.photopicker.core.features.FeatureRegistration 39 import com.android.photopicker.core.features.FeatureToken 40 import com.android.photopicker.core.features.Location 41 import com.android.photopicker.core.features.LocationParams 42 import com.android.photopicker.core.features.PhotopickerUiFeature 43 import com.android.photopicker.core.features.PrefetchResultKey 44 import com.android.photopicker.core.features.Priority 45 import com.android.photopicker.core.navigation.Route 46 import com.android.photopicker.core.user.UserMonitor 47 import com.android.photopicker.data.DataService 48 import com.android.photopicker.data.model.CollectionInfo 49 import com.android.photopicker.data.model.MediaSource 50 import com.android.photopicker.data.model.Provider 51 import com.android.photopicker.features.overflowmenu.OverflowMenuItem 52 import kotlinx.coroutines.Deferred 53 import kotlinx.coroutines.launch 54 55 /** 56 * Feature class for the Photopicker's cloud media implementation. 57 * 58 * This feature adds the Cloud media preloader for preloading off-device content before the 59 * Photopicker session ends. 60 */ 61 class CloudMediaFeature : PhotopickerUiFeature { 62 companion object Registration : FeatureRegistration { 63 override val TAG: String = "PhotopickerCloudMediaFeature" 64 isEnablednull65 override fun isEnabled( 66 config: PhotopickerConfiguration, 67 deferredPrefetchResultsMap: Map<PrefetchResultKey, Deferred<Any?>>, 68 ): Boolean { 69 70 // Cloud media is not available in permission mode. 71 if (config.action == MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP) return false 72 73 return config.flags.CLOUD_MEDIA_ENABLED && 74 config.flags.CLOUD_ALLOWED_PROVIDERS.isNotEmpty() 75 } 76 buildnull77 override fun build(featureManager: FeatureManager) = CloudMediaFeature() 78 } 79 80 override val token = FeatureToken.CLOUD_MEDIA.token 81 82 override val ownedBanners: Set<BannerDefinitions> = 83 setOf( 84 BannerDefinitions.CLOUD_CHOOSE_ACCOUNT, 85 BannerDefinitions.CLOUD_CHOOSE_PROVIDER, 86 BannerDefinitions.CLOUD_MEDIA_AVAILABLE, 87 ) 88 89 override suspend fun getBannerPriority( 90 banner: BannerDefinitions, 91 bannerState: BannerState?, 92 config: PhotopickerConfiguration, 93 dataService: DataService, 94 userMonitor: UserMonitor, 95 ): Int { 96 97 val isEmbedded = config.runtimeEnv == PhotopickerRuntimeEnv.EMBEDDED 98 // If any of the banners owned by [CloudMediaFeature] have been previously dismissed, then 99 // return a disabled priority. 100 if (bannerState?.dismissed == true) { 101 return Priority.DISABLED.priority 102 } 103 104 // Attempt to find a [REMOTE] provider in the available list of providers. 105 val currentCloudProvider: Provider? = 106 dataService.availableProviders.value.firstOrNull { 107 it.mediaSource == MediaSource.REMOTE 108 } 109 110 // If one is found, fetch the collectionInfo for that provider. 111 val collectionInfo: CollectionInfo? = 112 currentCloudProvider?.let { dataService.getCollectionInfo(it) } 113 114 return when (banner) { 115 BannerDefinitions.CLOUD_CHOOSE_PROVIDER -> { 116 return when { 117 // Don't show in Embedded, as the banner starts an activity which can cause a 118 // crash. 119 isEmbedded -> Priority.DISABLED.priority 120 121 // If there is no current provider, but a list of allowed providers exists 122 currentCloudProvider == null && 123 dataService.getAllAllowedProviders().isNotEmpty() -> 124 Priority.MEDIUM.priority 125 126 // There's a cloud provider set, so don't show 127 else -> Priority.DISABLED.priority 128 } 129 } 130 BannerDefinitions.CLOUD_CHOOSE_ACCOUNT -> { 131 collectionInfo?.let { 132 when { 133 // Don't show in Embedded, as the banner starts an activity which can cause 134 // a crash. 135 isEmbedded -> Priority.DISABLED.priority 136 137 // If there is no current cloud provider account 138 it.accountName == null -> Priority.MEDIUM.priority 139 140 // There's a cloud provider account set, so don't show 141 else -> Priority.DISABLED.priority 142 } 143 } ?: Priority.DISABLED.priority 144 } 145 BannerDefinitions.CLOUD_MEDIA_AVAILABLE -> { 146 collectionInfo?.let { 147 if (it.accountName != null && it.collectionId != null) { 148 Priority.MEDIUM.priority 149 } else { 150 Priority.DISABLED.priority 151 } 152 } ?: Priority.DISABLED.priority 153 } 154 else -> 155 throw IllegalArgumentException("$TAG cannot build the requested banner: $banner") 156 } 157 } 158 buildBannernull159 override suspend fun buildBanner( 160 banner: BannerDefinitions, 161 dataService: DataService, 162 userMonitor: UserMonitor, 163 ): Banner { 164 165 val cloudProvider: Provider? = 166 dataService.availableProviders.value.firstOrNull { 167 it.mediaSource == MediaSource.REMOTE 168 } 169 170 val collectionInfo: CollectionInfo? = 171 cloudProvider?.let { dataService.getCollectionInfo(it) } 172 173 return when (banner) { 174 BannerDefinitions.CLOUD_CHOOSE_PROVIDER -> cloudChooseProviderBanner 175 BannerDefinitions.CLOUD_CHOOSE_ACCOUNT -> 176 buildCloudChooseAccountBanner( 177 cloudProvider = 178 checkNotNull(cloudProvider) { "cloudProvider was null during buildBanner" }, 179 collectionInfo = 180 checkNotNull(collectionInfo) { 181 "collectionInfo was null during buildBanner" 182 }, 183 ) 184 BannerDefinitions.CLOUD_MEDIA_AVAILABLE -> 185 buildCloudMediaAvailableBanner( 186 cloudProvider = 187 checkNotNull(cloudProvider) { "cloudProvider was null during buildBanner" }, 188 collectionInfo = 189 checkNotNull(collectionInfo) { 190 "collectionInfo was null during buildBanner" 191 }, 192 ) 193 else -> 194 throw IllegalArgumentException("$TAG cannot build the requested banner: $banner") 195 } 196 } 197 198 /** Events consumed by Cloud Media */ 199 override val eventsConsumed = setOf<RegisteredEventClass>() 200 201 /** Events produced by the Cloud Media */ 202 override val eventsProduced = 203 setOf<RegisteredEventClass>( 204 Event.LogPhotopickerMenuInteraction::class.java, 205 Event.LogPhotopickerUIEvent::class.java, 206 ) 207 registerLocationsnull208 override fun registerLocations(): List<Pair<Location, Int>> { 209 return listOf( 210 // Medium priority for OVERFLOW_MENU_ITEMS so that [BrowseFeature] can 211 // have the top spot if it's enabled. 212 Pair(Location.OVERFLOW_MENU_ITEMS, Priority.MEDIUM.priority) 213 ) 214 } 215 registerNavigationRoutesnull216 override fun registerNavigationRoutes(): Set<Route> { 217 return setOf() 218 } 219 220 @Composable composenull221 override fun compose(location: Location, modifier: Modifier, params: LocationParams) { 222 val events = LocalEvents.current 223 val scope = rememberCoroutineScope() 224 val configuration = LocalPhotopickerConfiguration.current 225 when (location) { 226 Location.OVERFLOW_MENU_ITEMS -> { 227 val context = LocalContext.current 228 val clickAction = params as? LocationParams.WithClickAction 229 OverflowMenuItem( 230 label = stringResource(R.string.photopicker_overflow_cloud_media_app), 231 onClick = { 232 clickAction?.onClick() 233 context.startActivity(Intent(MediaStore.ACTION_PICK_IMAGES_SETTINGS)) 234 // Dispatch event to log user's interactiuon with the cloud settings menu 235 // item in the photopicker 236 scope.launch { 237 events.dispatch( 238 Event.LogPhotopickerMenuInteraction( 239 token, 240 configuration.sessionId, 241 configuration.callingPackageUid ?: -1, 242 Telemetry.MenuItemSelected.CLOUD_SETTINGS, 243 ) 244 ) 245 } 246 }, 247 ) 248 } 249 else -> {} 250 } 251 } 252 } 253