blob: 923963ad40885d29c26ec35c59f6b2e67996af70 [file] [log] [blame]
/*
* 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
import android.content.ClipData
import android.content.ComponentName
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.UserHandle
import android.provider.MediaStore
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.android.photopicker.core.Background
import com.android.photopicker.core.PhotopickerAppWithBottomSheet
import com.android.photopicker.core.configuration.ConfigurationManager
import com.android.photopicker.core.configuration.IllegalIntentExtraException
import com.android.photopicker.core.configuration.LocalPhotopickerConfiguration
import com.android.photopicker.core.events.Event
import com.android.photopicker.core.events.Events
import com.android.photopicker.core.events.LocalEvents
import com.android.photopicker.core.features.FeatureManager
import com.android.photopicker.core.features.LocalFeatureManager
import com.android.photopicker.core.selection.LocalSelection
import com.android.photopicker.core.selection.Selection
import com.android.photopicker.core.theme.PhotopickerTheme
import com.android.photopicker.data.model.Media
import com.android.photopicker.extensions.canHandleGetContentIntentMimeTypes
import com.android.photopicker.features.cloudmedia.CloudMediaFeature
import dagger.Lazy
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.scopes.ActivityRetainedScoped
import javax.inject.Inject
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* This is the main entrypoint into the Android Photopicker.
*
* This class is responsible for bootstrapping the launched activity, session related dependencies,
* and providing the compose ui entrypoint in [[PhotopickerApp]] with everything it needs.
*/
@AndroidEntryPoint(ComponentActivity::class)
class MainActivity : Hilt_MainActivity() {
@Inject @ActivityRetainedScoped lateinit var configurationManager: ConfigurationManager
@Inject @ActivityRetainedScoped lateinit var processOwnerUserHandle: UserHandle
@Inject @ActivityRetainedScoped lateinit var selection: Lazy<Selection<Media>>
// This needs to be injected lazily, to defer initialization until the action can be set
// on the ConfigurationManager.
@Inject @ActivityRetainedScoped lateinit var featureManager: Lazy<FeatureManager>
@Inject @Background lateinit var background: CoroutineDispatcher
// Events requires the feature manager, so initialize this lazily until the action is set.
@Inject lateinit var events: Lazy<Events>
companion object {
val TAG: String = "Photopicker"
}
/**
* A flow used to trigger the preloader. When media is ready to be preloaded it should be
* provided to the preloader by emitting into this flow.
*
* The main activity should create a new [_preloadDeferred] before emitting, and then monitor
* that deferred to obtain the result of the preload operation that this flow will trigger.
*/
val preloadMedia: MutableSharedFlow<Set<Media>> = MutableSharedFlow()
/**
* A deferred which tracks the current state of any preload operation requested by the main
* activity.
*/
private var _preloadDeferred: CompletableDeferred<Boolean> = CompletableDeferred()
/**
* Public access to the deferred, behind a getter. (To ensure any access to this property always
* obtains the latest value)
*/
public val preloadDeferred: CompletableDeferred<Boolean>
get() {
return _preloadDeferred
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// [ACTION_GET_CONTENT]: Check to see if Photopicker should handle this session, or if the
// user should instead be referred to [com.android.documentsui]. This is necessary because
// Photopicker has a higher priority for "image/*" and "video/*" mimetypes that DocumentsUi.
// An unfortunate side effect is that a mimetype of "*/*" also matches Photopicker's
// intent-filter, and in that case, the user is not in a pure media selection mode, so refer
// the user to DocumentsUi to handle all file types.
if (shouldRerouteGetContentRequest()) {
referToDocumentsUi()
}
enableEdgeToEdge()
// Set the action before allowing FeatureManager to be initialized, so that it receives
// the correct config with this activity's action.
try {
getIntent()?.let { configurationManager.setIntent(it) }
} catch (exception: IllegalIntentExtraException) {
// If the incoming intent contains intent extras that are not supported in the current
// configuration, then cancel the activity and close.
Log.e(TAG, "Unable to start Photopicker with illegal configuration", exception)
setResult(RESULT_CANCELED)
finish()
}
// Begin listening for events before starting the UI.
listenForEvents()
/*
* In single select sessions, the activity needs to end after a media object is selected,
* so register a listener to the selection so the activity can handle calling
* [onMediaSelectionConfirmed] itself.
*
* For multi-select, the activity has to wait for onMediaSelectionConfirmed to be called
* by the selection bar click handler, or for the [Event.MediaSelectionConfirmed], in
* the event the user ends the session from the [PreviewFeature]
*/
listenForSelectionIfSingleSelect()
setContent {
val photopickerConfiguration by
configurationManager.configuration.collectAsStateWithLifecycle()
// Provide values to the entire compose stack.
CompositionLocalProvider(
LocalFeatureManager provides featureManager.get(),
LocalPhotopickerConfiguration provides photopickerConfiguration,
LocalSelection provides selection.get(),
LocalEvents provides events.get(),
) {
PhotopickerTheme(intent = photopickerConfiguration.intent) {
PhotopickerAppWithBottomSheet(
onDismissRequest = ::finish,
onMediaSelectionConfirmed = {
lifecycleScope.launch {
// Move the work off the UI dispatcher.
withContext(background) { onMediaSelectionConfirmed() }
}
},
preloadMedia = preloadMedia,
obtainPreloaderDeferred = { preloadDeferred }
)
}
}
}
}
/**
* A collector that starts when Photopicker is running in single-select mode. This collector
* will trigger [onMediaSelectionConfirmed] when the first (and only) item is selected.
*/
private fun listenForSelectionIfSingleSelect() {
// Only set up a collector if the selection limit is 1, otherwise the [SelectionBarFeature]
// will be enabled for the user to confirm the selection.
if (configurationManager.configuration.value.selectionLimit == 1) {
lifecycleScope.launch {
withContext(background) {
selection.get().flow.collect {
if (it.size == 1) {
onMediaSelectionConfirmed()
}
}
}
}
}
}
/** Setup an [Event] listener for the [MainActivity] to monitor the event bus. */
private fun listenForEvents() {
lifecycleScope.launch {
events.get().flow.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collect { event
->
when (event) {
/**
* [MediaSelectionConfirmed] will be dispatched in response to the user
* confirming their selection of Media in the UI.
*/
is Event.MediaSelectionConfirmed -> onMediaSelectionConfirmed()
else -> {}
}
}
}
}
/**
* Entrypoint for confirming the set of selected media and preparing the media for the calling
* application.
*
* This should be called when the user has confirmed their selection, and would like to exit
* photopicker and grant access to the media to the calling application.
*
* This will result in access being issued to the calling app if the media can be successfully
* prepared.
*/
private suspend fun onMediaSelectionConfirmed() {
val snapshot = selection.get().snapshot()
// Determine if any preload of the selected media needs to happen, and
// await the result of the preloader before proceeding.
if (featureManager.get().isFeatureEnabled(CloudMediaFeature::class.java)) {
// Create a new [CompletableDeferred] that represents the result of this
// preload operation
_preloadDeferred = CompletableDeferred()
preloadMedia.emit(snapshot)
// Await a response from the deferred before proceeding.
// This will suspend until the response is available.
val preloadSuccessful = _preloadDeferred.await()
// The preload failed, so the activity cannot be completed.
if (!preloadSuccessful) {
return
}
}
val deselectionSnapshot = selection.get().getDeselection().toHashSet()
onMediaSelectionReady(snapshot, deselectionSnapshot)
}
/**
* This will end the activity.
*
* This method should be called when the user has confirmed their selection of media and would
* like to exit the Photopicker. All Media preloading should be completed before this method is
* invoked. This method will then arrange for the correct data to be returned based on the
* configuration Photopicker is running under.
*
* When this method is complete, the Photopicker session will end.
*
* @param selection The prepared media that is ready to be returned to the caller.
* @see [setResultForApp] for modes where the Photopicker returns media directly to the caller
* @see [issueGrantsForApp] for permission mode grant writing in MediaProvider
*/
private suspend fun onMediaSelectionReady(selection: Set<Media>, deselection: Set<Media>) {
val configuration = configurationManager.configuration.first()
when (configuration.action) {
MediaStore.ACTION_PICK_IMAGES,
Intent.ACTION_GET_CONTENT ->
setResultForApp(selection, canSelectMultiple = configuration.selectionLimit > 1)
MediaStore.ACTION_USER_SELECT_IMAGES_FOR_APP -> {
val uid =
configuration.intent?.getExtras()?.getInt(Intent.EXTRA_UID)
// If the permission controller did not provide a uid, there is no way to
// continue.
?: throw IllegalStateException(
"Expected a uid to provided by PermissionController."
)
updateGrantsForApp(selection, deselection, uid)
}
else -> {}
}
finish()
}
/**
* The selection must be returned to the calling app via [setResult] and [ClipData]. When the
* [MainActivity] is ending, this is part of the sequence of events to close the picker and
* provide the selected media uris to the caller.
*
* This work runs on the @Background [CoroutineDispatcher] to avoid any UI disruption.
*
* @param selection the prepared media that can be safely returned to the app.
* @param canSelectMultiple whether photopicker is in multi-select mode.
*/
private suspend fun setResultForApp(selection: Set<Media>, canSelectMultiple: Boolean) {
if (selection.size < 1) return
val resultData = Intent()
val uris: MutableList<Uri> = selection.map { it.mediaUri }.toMutableList()
if (!canSelectMultiple) {
// For Single selection set the Uri on the intent directly.
resultData.setData(uris.removeFirst())
} else if (uris.isNotEmpty()) {
// For multi-selection, returned data needs to be attached via [ClipData]
val clipData =
ClipData(
/* label= */ null,
/* mimeTypes= */ selection.map { it.mimeType }.distinct().toTypedArray(),
/* item= */ ClipData.Item(uris.removeFirst())
)
// If there are any remaining items in the list, attach those as additional
// [ClipData.Item]
for (uri in uris) {
clipData.addItem(ClipData.Item(uri))
}
resultData.setClipData(clipData)
} else {
// The selection is empty, and there is no data to return to the caller.
setResult(RESULT_CANCELED)
return
}
resultData.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
resultData.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
setResult(RESULT_OK, resultData)
}
/**
* When Photopicker is in permission mode, the PermissionController is the calling application,
* and rather than returning a list of media uris to the caller, instead MediaGrants must be
* generated for the app uid provided by the PermissionController. (Which in this context is the
* app that has invoked the permission controller, and thus caused PermissionController to open
* photopicker).
*
* In addition to this, the preGranted items that are now de-selected by the user, the app
* should no longer hold MediaGrants for them. This method takes care of revoking these grants.
*
* This is part of the sequence of ending a Photopicker Session, and is done in place of
* returning data to the caller.
*
* @param selection The prepared media that is ready to be returned to the caller.
* @param deselection The media for which the read grants should be revoked.
* @param uid The uid of the calling application to issue media grants for.
*/
private suspend fun updateGrantsForApp(
selection: Set<Media>,
deselection: Set<Media>,
uid: Int
) {
// Adding grants for items selected by the user.
val uris: List<Uri> = selection.map { it.mediaUri }
MediaStore.grantMediaReadForPackage(getApplicationContext(), uid, uris)
// Removing grants for preGranted items that have now been de-selected by the user.
val urisForItemsToBeRevoked = deselection.map { it.mediaUri }
MediaStore.revokeMediaReadForPackages(getApplicationContext(), uid, urisForItemsToBeRevoked)
// No need to send any data back to the PermissionController, just send an OK signal
// back to indicate the MediaGrants are available.
setResult(RESULT_OK)
}
/**
* This will end the activity. Refer the current session to [com.android.documentsui]
*
* Note: Complete any pending logging or work before calling this method as this will end the
* process immediately.
*/
private fun referToDocumentsUi() {
// The incoming intent is not changed in any way when redirecting to DocumentsUi.
// The calling app launched [ACTION_GET_CONTENT] probably without knowing it would first
// come to Photopicker, so if Photopicker isn't going to handle the intent, just pass it
// along unmodified.
@Suppress("UnsafeIntentLaunch") val intent = getIntent()
intent?.apply {
addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT)
addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP)
setComponent(getDocumentssUiComponentName())
}
startActivityAsUser(intent, processOwnerUserHandle)
finish()
}
/**
* Determines if this session should end and the user should be redirected to
* [com.android.documentsUi]. The evaluates the incoming [Intent] to see if Photopicker is
* running in [ACTION_GET_CONTENT], and if the mimetypes requested can be correctly handled by
* Photopicker. If the activity is not running in [ACTION_GET_CONTENT] this will always return
* false.
*
* A notable exception would be if Photopicker was started by DocumentsUi rather than the
* original app, in which case this method will return [false].
*
* @return true if the activity is running [ACTION_GET_CONTENT] and Photopicker shouldn't handle
* the session.
*/
private fun shouldRerouteGetContentRequest(): Boolean {
val intent = getIntent()
return when {
Intent.ACTION_GET_CONTENT != intent.getAction() -> false
// GET_CONTENT for all (media and non-media) files opens DocumentsUi, but it still shows
// "Photo Picker app option. When the user clicks on "Photo Picker", the same intent
// which includes filters to show non-media files as well is forwarded to PhotoPicker.
// Make sure Photo Picker is opened when the intent is explicitly forwarded by
// documentsUi
isIntentReferredByDocumentsUi(getReferrer()) -> false
// Ensure Photopicker can handle the specified MIME types.
intent.canHandleGetContentIntentMimeTypes() -> false
else -> true
}
}
/**
* Resolves a [ComponentName] for DocumentsUi via [Intent.ACTION_OPEN_DOCUMENT]
*
* ACTION_OPEN_DOCUMENT is used to find DocumentsUi's component due to DocumentsUi being the
* default handler.
*
* @return the [ComponentName] for DocumentsUi's picker activity.
*/
private fun getDocumentssUiComponentName(): ComponentName? {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
setType("*/*")
}
val componentName = intent.resolveActivity(getPackageManager())
return componentName
}
/**
* Determines if the referrer uri came from [com.android.documentsui]
*
* @return true if the referrer [Uri] is from DocumentsUi.
*/
private fun isIntentReferredByDocumentsUi(referrer: Uri?): Boolean {
return referrer?.getHost() == getDocumentssUiComponentName()?.getPackageName()
}
}