1 /* <lambda>null2 * 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 18 19 import android.content.ClipData 20 import android.content.ComponentName 21 import android.content.Intent 22 import android.net.Uri 23 import android.os.Bundle 24 import android.os.UserHandle 25 import android.provider.MediaStore 26 import android.util.Log 27 import androidx.activity.ComponentActivity 28 import androidx.activity.compose.setContent 29 import androidx.activity.enableEdgeToEdge 30 import androidx.compose.runtime.CompositionLocalProvider 31 import androidx.compose.runtime.getValue 32 import androidx.lifecycle.Lifecycle 33 import androidx.lifecycle.compose.collectAsStateWithLifecycle 34 import androidx.lifecycle.flowWithLifecycle 35 import androidx.lifecycle.lifecycleScope 36 import com.android.photopicker.core.Background 37 import com.android.photopicker.core.PhotopickerAppWithBottomSheet 38 import com.android.photopicker.core.configuration.ConfigurationManager 39 import com.android.photopicker.core.configuration.IllegalIntentExtraException 40 import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration 41 import com.android.photopicker.core.events.Event 42 import com.android.photopicker.core.events.Events 43 import com.android.photopicker.core.events.LocalEvents 44 import com.android.photopicker.core.features.FeatureManager 45 import com.android.photopicker.core.features.LocalFeatureManager 46 import com.android.photopicker.core.selection.LocalSelection 47 import com.android.photopicker.core.selection.Selection 48 import com.android.photopicker.core.theme.PhotopickerTheme 49 import com.android.photopicker.data.model.Media 50 import com.android.photopicker.extensions.canHandleGetContentIntentMimeTypes 51 import com.android.photopicker.features.cloudmedia.CloudMediaFeature 52 import dagger.Lazy 53 import dagger.hilt.android.AndroidEntryPoint 54 import dagger.hilt.android.scopes.ActivityRetainedScoped 55 import javax.inject.Inject 56 import kotlinx.coroutines.CompletableDeferred 57 import kotlinx.coroutines.CoroutineDispatcher 58 import kotlinx.coroutines.flow.MutableSharedFlow 59 import kotlinx.coroutines.flow.first 60 import kotlinx.coroutines.launch 61 import kotlinx.coroutines.withContext 62 63 /** 64 * This is the main entrypoint into the Android Photopicker. 65 * 66 * This class is responsible for bootstrapping the launched activity, session related dependencies, 67 * and providing the compose ui entrypoint in [[PhotopickerApp]] with everything it needs. 68 */ 69 @AndroidEntryPoint(ComponentActivity::class) 70 class MainActivity : Hilt_MainActivity() { 71 72 @Inject @ActivityRetainedScoped lateinit var configurationManager: ConfigurationManager 73 @Inject @ActivityRetainedScoped lateinit var processOwnerUserHandle: UserHandle 74 @Inject @ActivityRetainedScoped lateinit var selection: Lazy<Selection<Media>> 75 // This needs to be injected lazily, to defer initialization until the action can be set 76 // on the ConfigurationManager. 77 @Inject @ActivityRetainedScoped lateinit var featureManager: Lazy<FeatureManager> 78 @Inject @Background lateinit var background: CoroutineDispatcher 79 80 // Events requires the feature manager, so initialize this lazily until the action is set. 81 @Inject lateinit var events: Lazy<Events> 82 83 companion object { 84 val TAG: String = "Photopicker" 85 } 86 87 /** 88 * A flow used to trigger the preloader. When media is ready to be preloaded it should be 89 * provided to the preloader by emitting into this flow. 90 * 91 * The main activity should create a new [_preloadDeferred] before emitting, and then monitor 92 * that deferred to obtain the result of the preload operation that this flow will trigger. 93 */ 94 val preloadMedia: MutableSharedFlow<Set<Media>> = MutableSharedFlow() 95 96 /** 97 * A deferred which tracks the current state of any preload operation requested by the main 98 * activity. 99 */ 100 private var _preloadDeferred: CompletableDeferred<Boolean> = CompletableDeferred() 101 102 /** 103 * Public access to the deferred, behind a getter. (To ensure any access to this property always 104 * obtains the latest value) 105 */ 106 public val preloadDeferred: CompletableDeferred<Boolean> 107 get() { 108 return _preloadDeferred 109 } 110 111 override fun onCreate(savedInstanceState: Bundle?) { 112 super.onCreate(savedInstanceState) 113 114 // [ACTION_GET_CONTENT]: Check to see if Photopicker should handle this session, or if the 115 // user should instead be referred to [com.android.documentsui]. This is necessary because 116 // Photopicker has a higher priority for "image/*" and "video/*" mimetypes that DocumentsUi. 117 // An unfortunate side effect is that a mimetype of "*/*" also matches Photopicker's 118 // intent-filter, and in that case, the user is not in a pure media selection mode, so refer 119 // the user to DocumentsUi to handle all file types. 120 if (shouldRerouteGetContentRequest()) { 121 referToDocumentsUi() 122 } 123 124 enableEdgeToEdge() 125 126 // Set the action before allowing FeatureManager to be initialized, so that it receives 127 // the correct config with this activity's action. 128 try { 129 getIntent()?.let { configurationManager.setIntent(it) } 130 } catch (exception: IllegalIntentExtraException) { 131 // If the incoming intent contains intent extras that are not supported in the current 132 // configuration, then cancel the activity and close. 133 Log.e(TAG, "Unable to start Photopicker with illegal configuration", exception) 134 setResult(RESULT_CANCELED) 135 finish() 136 } 137 138 // Begin listening for events before starting the UI. 139 listenForEvents() 140 141 /* 142 * In single select sessions, the activity needs to end after a media object is selected, 143 * so register a listener to the selection so the activity can handle calling 144 * [onMediaSelectionConfirmed] itself. 145 * 146 * For multi-select, the activity has to wait for onMediaSelectionConfirmed to be called 147 * by the selection bar click handler, or for the [Event.MediaSelectionConfirmed], in 148 * the event the user ends the session from the [PreviewFeature] 149 */ 150 listenForSelectionIfSingleSelect() 151 152 setContent { 153 val photopickerConfiguration by 154 configurationManager.configuration.collectAsStateWithLifecycle() 155 // Provide values to the entire compose stack. 156 CompositionLocalProvider( 157 LocalFeatureManager provides featureManager.get(), 158 LocalPhotopickerConfiguration provides photopickerConfiguration, 159 LocalSelection provides selection.get(), 160 LocalEvents provides events.get(), 161 ) { 162 PhotopickerTheme(intent = photopickerConfiguration.intent) { 163 PhotopickerAppWithBottomSheet( 164 onDismissRequest = ::finish, 165 onMediaSelectionConfirmed = { 166 lifecycleScope.launch { 167 // Move the work off the UI dispatcher. 168 withContext(background) { onMediaSelectionConfirmed() } 169 } 170 }, 171 preloadMedia = preloadMedia, 172 obtainPreloaderDeferred = { preloadDeferred } 173 ) 174 } 175 } 176 } 177 } 178 179 /** 180 * A collector that starts when Photopicker is running in single-select mode. This collector 181 * will trigger [onMediaSelectionConfirmed] when the first (and only) item is selected. 182 */ 183 private fun listenForSelectionIfSingleSelect() { 184 185 // Only set up a collector if the selection limit is 1, otherwise the [SelectionBarFeature] 186 // will be enabled for the user to confirm the selection. 187 if (configurationManager.configuration.value.selectionLimit == 1) { 188 lifecycleScope.launch { 189 withContext(background) { 190 selection.get().flow.collect { 191 if (it.size == 1) { 192 onMediaSelectionConfirmed() 193 } 194 } 195 } 196 } 197 } 198 } 199 200 /** Setup an [Event] listener for the [MainActivity] to monitor the event bus. */ 201 private fun listenForEvents() { 202 lifecycleScope.launch { 203 events.get().flow.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { event 204 -> 205 when (event) { 206 207 /** 208 * [MediaSelectionConfirmed] will be dispatched in response to the user 209 * confirming their selection of Media in the UI. 210 */ 211 is Event.MediaSelectionConfirmed -> onMediaSelectionConfirmed() 212 else -> {} 213 } 214 } 215 } 216 } 217 218 /** 219 * Entrypoint for confirming the set of selected media and preparing the media for the calling 220 * application. 221 * 222 * This should be called when the user has confirmed their selection, and would like to exit 223 * photopicker and grant access to the media to the calling application. 224 * 225 * This will result in access being issued to the calling app if the media can be successfully 226 * prepared. 227 */ 228 private suspend fun onMediaSelectionConfirmed() { 229 230 val snapshot = selection.get().snapshot() 231 // Determine if any preload of the selected media needs to happen, and 232 // await the result of the preloader before proceeding. 233 if (featureManager.get().isFeatureEnabled(CloudMediaFeature::class.java)) { 234 235 // Create a new [CompletableDeferred] that represents the result of this 236 // preload operation 237 _preloadDeferred = CompletableDeferred() 238 preloadMedia.emit(snapshot) 239 240 // Await a response from the deferred before proceeding. 241 // This will suspend until the response is available. 242 val preloadSuccessful = _preloadDeferred.await() 243 244 // The preload failed, so the activity cannot be completed. 245 if (!preloadSuccessful) { 246 return 247 } 248 } 249 val deselectionSnapshot = selection.get().getDeselection().toHashSet() 250 onMediaSelectionReady(snapshot, deselectionSnapshot) 251 } 252 253 /** 254 * This will end the activity. 255 * 256 * This method should be called when the user has confirmed their selection of media and would 257 * like to exit the Photopicker. All Media preloading should be completed before this method is 258 * invoked. This method will then arrange for the correct data to be returned based on the 259 * configuration Photopicker is running under. 260 * 261 * When this method is complete, the Photopicker session will end. 262 * 263 * @param selection The prepared media that is ready to be returned to the caller. 264 * @see [setResultForApp] for modes where the Photopicker returns media directly to the caller 265 * @see [issueGrantsForApp] for permission mode grant writing in MediaProvider 266 */ 267 private suspend fun onMediaSelectionReady(selection: Set<Media>, deselection: Set<Media>) { 268 269 val configuration = configurationManager.configuration.first() 270 271 when (configuration.action) { 272 MediaStore.ACTION_PICK_IMAGES, 273 Intent.ACTION_GET_CONTENT -> 274 setResultForApp(selection, canSelectMultiple = configuration.selectionLimit > 1) 275 MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP -> { 276 val uid = 277 configuration.intent?.getExtras()?.getInt(Intent.EXTRA_UID) 278 // If the permission controller did not provide a uid, there is no way to 279 // continue. 280 ?: throw IllegalStateException( 281 "Expected a uid to provided by PermissionController." 282 ) 283 updateGrantsForApp(selection, deselection, uid) 284 } 285 else -> {} 286 } 287 288 finish() 289 } 290 291 /** 292 * The selection must be returned to the calling app via [setResult] and [ClipData]. When the 293 * [MainActivity] is ending, this is part of the sequence of events to close the picker and 294 * provide the selected media uris to the caller. 295 * 296 * This work runs on the @Background [CoroutineDispatcher] to avoid any UI disruption. 297 * 298 * @param selection the prepared media that can be safely returned to the app. 299 * @param canSelectMultiple whether photopicker is in multi-select mode. 300 */ 301 private suspend fun setResultForApp(selection: Set<Media>, canSelectMultiple: Boolean) { 302 303 if (selection.size < 1) return 304 305 val resultData = Intent() 306 307 val uris: MutableList<Uri> = selection.map { it.mediaUri }.toMutableList() 308 309 if (!canSelectMultiple) { 310 // For Single selection set the Uri on the intent directly. 311 resultData.setData(uris.removeFirst()) 312 } else if (uris.isNotEmpty()) { 313 // For multi-selection, returned data needs to be attached via [ClipData] 314 val clipData = 315 ClipData( 316 /* label= */ null, 317 /* mimeTypes= */ selection.map { it.mimeType }.distinct().toTypedArray(), 318 /* item= */ ClipData.Item(uris.removeFirst()) 319 ) 320 321 // If there are any remaining items in the list, attach those as additional 322 // [ClipData.Item] 323 for (uri in uris) { 324 clipData.addItem(ClipData.Item(uri)) 325 } 326 resultData.setClipData(clipData) 327 } else { 328 // The selection is empty, and there is no data to return to the caller. 329 setResult(RESULT_CANCELED) 330 return 331 } 332 333 resultData.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 334 resultData.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) 335 336 setResult(RESULT_OK, resultData) 337 } 338 339 /** 340 * When Photopicker is in permission mode, the PermissionController is the calling application, 341 * and rather than returning a list of media uris to the caller, instead MediaGrants must be 342 * generated for the app uid provided by the PermissionController. (Which in this context is the 343 * app that has invoked the permission controller, and thus caused PermissionController to open 344 * photopicker). 345 * 346 * In addition to this, the preGranted items that are now de-selected by the user, the app 347 * should no longer hold MediaGrants for them. This method takes care of revoking these grants. 348 * 349 * This is part of the sequence of ending a Photopicker Session, and is done in place of 350 * returning data to the caller. 351 * 352 * @param selection The prepared media that is ready to be returned to the caller. 353 * @param deselection The media for which the read grants should be revoked. 354 * @param uid The uid of the calling application to issue media grants for. 355 */ 356 private suspend fun updateGrantsForApp( 357 selection: Set<Media>, 358 deselection: Set<Media>, 359 uid: Int 360 ) { 361 // Adding grants for items selected by the user. 362 val uris: List<Uri> = selection.map { it.mediaUri } 363 MediaStore.grantMediaReadForPackage(getApplicationContext(), uid, uris) 364 365 // Removing grants for preGranted items that have now been de-selected by the user. 366 val urisForItemsToBeRevoked = deselection.map { it.mediaUri } 367 MediaStore.revokeMediaReadForPackages(getApplicationContext(), uid, urisForItemsToBeRevoked) 368 369 // No need to send any data back to the PermissionController, just send an OK signal 370 // back to indicate the MediaGrants are available. 371 setResult(RESULT_OK) 372 } 373 374 /** 375 * This will end the activity. Refer the current session to [com.android.documentsui] 376 * 377 * Note: Complete any pending logging or work before calling this method as this will end the 378 * process immediately. 379 */ 380 private fun referToDocumentsUi() { 381 // The incoming intent is not changed in any way when redirecting to DocumentsUi. 382 // The calling app launched [ACTION_GET_CONTENT] probably without knowing it would first 383 // come to Photopicker, so if Photopicker isn't going to handle the intent, just pass it 384 // along unmodified. 385 @Suppress("UnsafeIntentLaunch") val intent = getIntent() 386 intent?.apply { 387 addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT) 388 addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP) 389 setComponent(getDocumentssUiComponentName()) 390 } 391 startActivityAsUser(intent, processOwnerUserHandle) 392 finish() 393 } 394 395 /** 396 * Determines if this session should end and the user should be redirected to 397 * [com.android.documentsUi]. The evaluates the incoming [Intent] to see if Photopicker is 398 * running in [ACTION_GET_CONTENT], and if the mimetypes requested can be correctly handled by 399 * Photopicker. If the activity is not running in [ACTION_GET_CONTENT] this will always return 400 * false. 401 * 402 * A notable exception would be if Photopicker was started by DocumentsUi rather than the 403 * original app, in which case this method will return [false]. 404 * 405 * @return true if the activity is running [ACTION_GET_CONTENT] and Photopicker shouldn't handle 406 * the session. 407 */ 408 private fun shouldRerouteGetContentRequest(): Boolean { 409 val intent = getIntent() 410 411 return when { 412 Intent.ACTION_GET_CONTENT != intent.getAction() -> false 413 414 // GET_CONTENT for all (media and non-media) files opens DocumentsUi, but it still shows 415 // "Photo Picker app option. When the user clicks on "Photo Picker", the same intent 416 // which includes filters to show non-media files as well is forwarded to PhotoPicker. 417 // Make sure Photo Picker is opened when the intent is explicitly forwarded by 418 // documentsUi 419 isIntentReferredByDocumentsUi(getReferrer()) -> false 420 421 // Ensure Photopicker can handle the specified MIME types. 422 intent.canHandleGetContentIntentMimeTypes() -> false 423 else -> true 424 } 425 } 426 427 /** 428 * Resolves a [ComponentName] for DocumentsUi via [Intent.ACTION_OPEN_DOCUMENT] 429 * 430 * ACTION_OPEN_DOCUMENT is used to find DocumentsUi's component due to DocumentsUi being the 431 * default handler. 432 * 433 * @return the [ComponentName] for DocumentsUi's picker activity. 434 */ 435 private fun getDocumentssUiComponentName(): ComponentName? { 436 437 val intent = 438 Intent(Intent.ACTION_OPEN_DOCUMENT).apply { 439 addCategory(Intent.CATEGORY_OPENABLE) 440 setType("*/*") 441 } 442 443 val componentName = intent.resolveActivity(getPackageManager()) 444 return componentName 445 } 446 447 /** 448 * Determines if the referrer uri came from [com.android.documentsui] 449 * 450 * @return true if the referrer [Uri] is from DocumentsUi. 451 */ 452 private fun isIntentReferredByDocumentsUi(referrer: Uri?): Boolean { 453 return referrer?.getHost() == getDocumentssUiComponentName()?.getPackageName() 454 } 455 } 456