blob: d333f4b2075de43a8c32cc6cf14a1ed680ce1378 [file]
/*
* 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.core.configuration
import android.content.Intent
import android.os.Build
import android.provider.DeviceConfig
import android.util.Log
import android.widget.photopicker.EmbeddedPhotoPickerFeatureInfo
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.isUnspecified
import com.android.modules.utils.build.SdkLevel
import com.android.photopicker.core.navigation.PhotopickerDestinations
import com.android.photopicker.core.theme.AccentColorHelper
import com.android.photopicker.extensions.getApplicationMediaCapabilities
import com.android.photopicker.extensions.getPhotopickerMimeTypes
import com.android.photopicker.extensions.getPhotopickerSelectionLimitOrDefault
import com.android.photopicker.extensions.getPickImagesInOrderEnabled
import com.android.photopicker.extensions.getPickImagesPreSelectedUris
import com.android.photopicker.extensions.getStartDestination
import com.android.providers.media.flags.Flags
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.launch
/**
* This class is responsible for providing the current configuration for the running Photopicker
* Activity.
*
* See [PhotopickerConfiguration] for details about all the various pieces that make up the session
* configuration.
*
* Provides a long-living [StateFlow] that emits the currently known configuration. Configuration
* changes may take up to 1000 ms to be emitted after processing. This delay is intentional, trying
* to batch any changes to configuration together, as it is anticipated that configuration changes
* will cause lots of re-calculation of downstream state.
*
* @property runtimeEnv The current [PhotopickerRuntimeEnv] environment, this value is used to
* create the initial [PhotopickerConfiguration], and should never be changed during subsequent
* configuration updates.
* @property scope The [CoroutineScope] the configuration flow will be shared in.
* @property dispatcher [CoroutineDispatcher] context that the DeviceConfig listener will execute
* in.
* @property deviceConfigProxy This is provided to the ConfigurationManager to better support
* testing various device flags, without relying on the device's actual flags at test time.
* @property sessionId A randomly generated integer to identify the current photopicker session
*/
class ConfigurationManager(
private val runtimeEnv: PhotopickerRuntimeEnv,
private val scope: CoroutineScope,
private val dispatcher: CoroutineDispatcher,
private val deviceConfigProxy: DeviceConfigProxy,
private val sessionId: Int,
) {
companion object {
val TAG: String = "PhotopickerConfigurationManager"
val DEBOUNCE_DELAY = 1000L
}
/*
* Internal [PhotopickerConfiguration] flow. When the configuration changes, this is what should
* be updated to ensure all listeners are notified.
*
* Note: Updating this is expensive and should be avoided (or batched, if possible).
* This will cause a recalculation of the active features and will likely result in the UI
* being re-composed from the top of the tree.
*/
private val _configuration: MutableStateFlow<PhotopickerConfiguration> =
MutableStateFlow(generateInitialConfiguration())
/* Exposes the current configuration used by Photopicker as a ReadOnly StateFlow. */
val configuration: StateFlow<PhotopickerConfiguration> = _configuration
/**
* Setup a [DeviceConfig.OnPropertiesChangedListener] to receive DeviceConfig changes to flags
* at runtime.
*/
val deviceConfigProxyChanges =
callbackFlow<PhotopickerFlags> {
val callback =
object : DeviceConfig.OnPropertiesChangedListener {
override fun onPropertiesChanged(properties: DeviceConfig.Properties) {
trySend(getFlagsFromDeviceConfig())
}
}
deviceConfigProxy.addOnPropertiesChangedListener(
NAMESPACE_MEDIAPROVIDER,
dispatcher.asExecutor(),
callback,
)
awaitClose {
Log.d(TAG, "Unregistering listeners from DeviceConfig")
deviceConfigProxy.removeOnPropertiesChangedListener(callback)
}
}
init {
scope.launch(dispatcher) {
// Batch any updates for 1000ms to catch multiple flags being updated at the
// same time, since the downstream configuration changes are expensive.
@OptIn(kotlinx.coroutines.FlowPreview::class)
deviceConfigProxyChanges.debounce(DEBOUNCE_DELAY).collect { flags ->
Log.d(TAG, "Configuration is being updated! Received updated Device flags: $flags")
_configuration.updateAndGet { it.copy(flags = flags) }
}
}
}
/**
* Updates the [PhotopickerConfiguration] with the [EmbeddedPhotopickerFeatureInfo] that the
* Embedded Photopicker is running with.
*
* Since [ConfigurationManager] is bound to the [EmbeddedServiceComponent], it does not have a
* reference to the currently running Session (if there is one). This allows the session to set
* the current FeatureInfo externally once the session is available.
*
* It's important that this method is called before the FeatureManager is started to prevent the
* feature manager from being re-initialized.
*/
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun setEmbeddedPhotopickerFeatureInfo(featureInfo: EmbeddedPhotoPickerFeatureInfo) {
Log.d(TAG, "New featureInfo received: $featureInfo : Configuration will now update.")
val selectionLimit = featureInfo.maxSelectionLimit
val mimeTypes = featureInfo.mimeTypes
val preSelectedUris = featureInfo.preSelectedUris
/**
* Pick images in order is a combination of circumstances:
* - selectionLimit mode must be multiselect (more than 1)
* - The feature must be requested from the caller in the featureInfo
*/
val pickImagesInOrder = featureInfo.isOrderedSelection && (selectionLimit > 1)
/** Check if the accent color was set and is valid. */
val accentColor =
with(AccentColorHelper(featureInfo.accentColor)) {
if (getAccentColor().isUnspecified) {
null
} else {
inputColor
}
}
// Use updateAndGet to ensure that the values are set before this method returns so that
// the new configuration is immediately available to the new subscribers.
_configuration.updateAndGet {
it.copy(
selectionLimit = selectionLimit,
accentColor = accentColor,
mimeTypes = mimeTypes.toCollection(ArrayList()),
preSelectedUris = preSelectedUris.toCollection(ArrayList()),
pickImagesInOrder = pickImagesInOrder,
)
}
}
/**
* Sets the current intent & action Photopicker is running under.
*
* Since [ConfigurationManager] is bound to the [ActivityRetainedComponent] it does not have a
* reference to the currently running Activity (if there is one.). This allows the activity to
* set the current Intent externally once the activity is available.
*
* If Photopicker is running inside of an activity, it's important that this method is called
* before the FeatureManager is started to prevent the feature manager being re-initialized.
*/
fun setIntent(intent: Intent) {
Log.d(TAG, "New intent received: $intent : Configuration will now update.")
// Check for [MediaStore.EXTRA_PICK_IMAGES_MAX] and update the selection limit accordingly.
val selectionLimit =
intent.getPhotopickerSelectionLimitOrDefault(default = DEFAULT_SELECTION_LIMIT)
// MimeTypes can explicitly be passed in the intent extras, so extract them if they exist
// (and are actually a media mimetype that is supported). If nothing is in the intent,
// just set what is already set in the current configuration.
val mimeTypes = intent.getPhotopickerMimeTypes() ?: _configuration.value.mimeTypes
/**
* Pick images in order is a combination of circumstances:
* - selectionLimit mode must be multiselect (more than 1)
* - The extra must be requested from the caller in the intent
*/
val pickImagesInOrder =
intent.getPickImagesInOrderEnabled(default = false) && (selectionLimit > 1)
/** Handle [MediaStore.EXTRA_PICK_IMAGES_LAUNCH_TAB] extra if it's in the intent */
val startDestination = intent.getStartDestination(default = PhotopickerDestinations.DEFAULT)
/** Check if the accent color was set and is valid. */
val accentColor =
with(AccentColorHelper.withIntent(intent)) {
if (getAccentColor().isUnspecified) {
null
} else {
inputColor
}
}
// get preSelection URIs from intent.
val pickerPreSelectionUris = intent.getPickImagesPreSelectedUris()
// Get application media capabilities from intent. While ApplicationMediaCapabilities
// requires S+, we limit it use for transcoding to T+ for two reasons:
// 1. Underlying Transformer doesn't support S and S v2 due to inaccessible audio services.
// 2. HDR video, introduced in Android T, is where the need for transcoding comes from.
// For details, see b/365988031.
val applicationMediaCapabilities =
if (SdkLevel.isAtLeastT()) {
intent.getApplicationMediaCapabilities()
} else {
null
}
// Use updateAndGet to ensure the value is set before this method returns so the new
// intent is immediately available to new subscribers.
_configuration.updateAndGet {
it.copy(
action = intent.getAction() ?: "",
intent = intent,
selectionLimit = selectionLimit,
accentColor = accentColor,
mimeTypes = mimeTypes,
pickImagesInOrder = pickImagesInOrder,
startDestination = startDestination,
preSelectedUris = pickerPreSelectionUris,
callingPackageMediaCapabilities = applicationMediaCapabilities,
)
}
}
/**
* Sets data in [PhotopickerConfiguration] about the current caller, and emit an updated
* configuration.
*
* @param callingPackage the package name of the caller
* @param callingPackageUid the uid of the caller
* @param callingPackageLabel the display label of the caller
*/
fun setCaller(callingPackage: String?, callingPackageUid: Int?, callingPackageLabel: String?) {
Log.d(TAG, "Caller information updated : Configuration will now update.")
_configuration.updateAndGet {
it.copy(
callingPackage = callingPackage,
callingPackageUid = callingPackageUid,
callingPackageLabel = callingPackageLabel,
)
}
}
/** Assembles an initial configuration upon activity launch. */
private fun generateInitialConfiguration(): PhotopickerConfiguration {
val config =
PhotopickerConfiguration(
runtimeEnv = runtimeEnv,
action = "",
flags = getFlagsFromDeviceConfig(),
sessionId = sessionId,
)
Log.d(TAG, "Startup configuration: $config")
return config
}
/**
* Creates a new [PhotopickerFlags] object from the current [DeviceConfig] flag state.
*
* Any flags expected to be present in the [PhotopickerConfiguration] should be checked and
* explicitly set here.
*
* @return a [PhotopickerFlags] object representative of the current device flag state.
*/
private fun getFlagsFromDeviceConfig(): PhotopickerFlags {
return PhotopickerFlags(
CLOUD_ALLOWED_PROVIDERS =
getAllowlistedPackages(
deviceConfigProxy.getFlag(
NAMESPACE_MEDIAPROVIDER,
/* key= */ FEATURE_CLOUD_MEDIA_PROVIDER_ALLOWLIST.first,
/* defaultValue= */ FEATURE_CLOUD_MEDIA_PROVIDER_ALLOWLIST.second,
)
),
CLOUD_ENFORCE_PROVIDER_ALLOWLIST =
deviceConfigProxy.getFlag(
NAMESPACE_MEDIAPROVIDER,
/* key= */ FEATURE_CLOUD_ENFORCE_PROVIDER_ALLOWLIST.first,
/* defaultValue= */ FEATURE_CLOUD_ENFORCE_PROVIDER_ALLOWLIST.second,
),
CLOUD_MEDIA_ENABLED =
deviceConfigProxy.getFlag(
NAMESPACE_MEDIAPROVIDER,
/* key= */ FEATURE_CLOUD_MEDIA_FEATURE_ENABLED.first,
/* defaultValue= */ FEATURE_CLOUD_MEDIA_FEATURE_ENABLED.second,
),
PRIVATE_SPACE_ENABLED =
deviceConfigProxy.getFlag(
NAMESPACE_MEDIAPROVIDER,
/* key= */ FEATURE_PRIVATE_SPACE_ENABLED.first,
/* defaultValue= */ FEATURE_PRIVATE_SPACE_ENABLED.second,
),
MANAGED_SELECTION_ENABLED =
deviceConfigProxy.getFlag(
NAMESPACE_MEDIAPROVIDER,
/* key= */ FEATURE_PICKER_CHOICE_MANAGED_SELECTION.first,
/* defaultValue= */ FEATURE_PICKER_CHOICE_MANAGED_SELECTION.second,
),
PICKER_SEARCH_ENABLED = Flags.enablePhotopickerSearch(),
PICKER_DATESCRUBBER_ENABLED = Flags.enablePhotopickerDatescrubber(),
PICKER_TRANSCODING_ENABLED = Flags.enablePhotopickerTranscoding(),
)
}
/**
* BACKWARD COMPATIBILITY WORKAROUND Initially, instead of using package names when
* allow-listing and setting the system default CloudMediaProviders we used authorities.
*
* This, however, introduced a vulnerability, so we switched to using package names. But, by
* then, we had been allow-listing and setting default CMPs using authorities.
*
* Luckily for us, all of those CMPs had authorities in one the following formats:
* "${package-name}.cloudprovider" or "${package-name}.picker", e.g. "com.hooli.android.photos"
* package would implement a CMP with "com.hooli.android.photos.cloudpicker" authority.
*
* So, in order for the old allow-listings and defaults to work now, we try to extract package
* names from authorities by removing the ".cloudprovider" and ".cloudpicker" suffixes.
*
* In the future, we'll need to be careful if package names of cloud media apps end with
* "cloudprovider" or "cloudpicker".
*/
private fun getAllowlistedPackages(allowedProvidersArray: Array<String>): Array<String> {
return allowedProvidersArray
.map {
when {
it.endsWith(".cloudprovider") ->
it.substring(0, it.length - ".cloudprovider".length)
it.endsWith(".cloudpicker") ->
it.substring(0, it.length - ".cloudpicker".length)
else -> it
}
}
.toTypedArray<String>()
}
}