From 9d6c46a1cf081f16563f7d08bd0671dafbc01d33 Mon Sep 17 00:00:00 2001 From: Diya Date: Mon, 29 Jul 2024 09:33:35 +0000 Subject: [PhotoPickerToolV2] Include PickerChoice screen, handle permissions for images, videos or both in the PickerChoice screen Added the PickerChoice screen and allowed the user to give full access, partial access or no access to the media on their device, and also handled permissions for images, videos or both. Bug: 349514760 Test: m PhotoPickerToolV2 -j64 & adb install -r -d out/target/product//system/app/PhotoPickerToolV2/PhotoPickerToolV2.apk Flag: TEST_ONLY Change-Id: I5944bbabd7da63335c92b7ce3e14f9ac8f723483 --- tools/photopickerV2/Android.bp | 5 +- tools/photopickerV2/AndroidManifest.xml | 5 +- tools/photopickerV2/res/values/strings.xml | 10 + .../tools/photopickerv2/docsui/DocsUIViewModel.kt | 13 +- .../photopickerv2/photopicker/PhotoPickerScreen.kt | 5 +- .../pickerchoice/PickerChoiceScreen.kt | 235 +++++++++++++++++++-- .../pickerchoice/PickerChoiceViewModel.kt | 165 ++++++++++++++- .../tools/photopickerv2/utils/UIComponents.kt | 5 +- 8 files changed, 405 insertions(+), 38 deletions(-) (limited to 'tools') diff --git a/tools/photopickerV2/Android.bp b/tools/photopickerV2/Android.bp index 7b6d84afe..a8e6ac5aa 100644 --- a/tools/photopickerV2/Android.bp +++ b/tools/photopickerV2/Android.bp @@ -14,6 +14,7 @@ android_app { "androidx.activity_activity-compose", "androidx.compose.foundation_foundation", "androidx.compose.material3_material3", + "androidx.compose.runtime_runtime-livedata", "androidx.compose.runtime_runtime", "androidx.compose.ui_ui", "androidx.core_core-ktx", @@ -40,6 +41,6 @@ android_app { ], srcs: ["src/**/*.kt"], sdk_version: "module_current", - target_sdk_version: "30", - min_sdk_version: "30", + target_sdk_version: "34", + min_sdk_version: "34", } diff --git a/tools/photopickerV2/AndroidManifest.xml b/tools/photopickerV2/AndroidManifest.xml index f176fa716..6416230fc 100644 --- a/tools/photopickerV2/AndroidManifest.xml +++ b/tools/photopickerV2/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + + tools:targetApi="33"> Enter a valid number greater than one Pick Media Working on it + Enable Pre-selection + Request Permissions + Request Permissions for: + + Picker Choice feature is only available for devices with Android U and above. + \n\nPlease upgrade your device to use this feature. + Images + Videos + Both Images and Videos + Show Latest Selection Only \ No newline at end of file diff --git a/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/docsui/DocsUIViewModel.kt b/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/docsui/DocsUIViewModel.kt index adc52e777..46e878144 100644 --- a/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/docsui/DocsUIViewModel.kt +++ b/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/docsui/DocsUIViewModel.kt @@ -49,20 +49,19 @@ class DocsUIViewModel( launcher: (Intent) -> Unit ): String? { - var finalMimeType = "" - if (allowCustomMimeType) finalMimeType = customMimeTypeInput - else if (selectedMimeType != "") finalMimeType = selectedMimeType - else finalMimeType = "*/*" - val intent = if (isActionGetContentSelected) { Intent(Intent.ACTION_GET_CONTENT).apply { - type = finalMimeType + if (allowCustomMimeType) type = customMimeTypeInput + else if (selectedMimeType != "") type = selectedMimeType + else type = "*/*" putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple) addCategory(Intent.CATEGORY_OPENABLE) } } else if (isOpenDocumentSelected) { Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - type = finalMimeType + if (allowCustomMimeType) type = customMimeTypeInput + else if (selectedMimeType != "") type = selectedMimeType + else type = "*/*" putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple) addCategory(Intent.CATEGORY_OPENABLE) } diff --git a/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/photopicker/PhotoPickerScreen.kt b/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/photopicker/PhotoPickerScreen.kt index f72a110c3..5e6fd45f9 100644 --- a/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/photopicker/PhotoPickerScreen.kt +++ b/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/photopicker/PhotoPickerScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -370,7 +369,7 @@ fun PhotoPickerScreen(photoPickerViewModel: PhotoPickerViewModel = viewModel()) contentDescription = null, modifier = Modifier .fillMaxWidth() - .fillMaxSize() + .height(600.dp) .padding(top = 8.dp) ) } else { @@ -384,7 +383,7 @@ fun PhotoPickerScreen(photoPickerViewModel: PhotoPickerViewModel = viewModel()) }, modifier = Modifier .fillMaxWidth() - .height(200.dp) + .height(600.dp) .padding(top = 8.dp) ) } diff --git a/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/pickerchoice/PickerChoiceScreen.kt b/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/pickerchoice/PickerChoiceScreen.kt index 65fe38d2c..093598db1 100644 --- a/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/pickerchoice/PickerChoiceScreen.kt +++ b/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/pickerchoice/PickerChoiceScreen.kt @@ -15,47 +15,244 @@ */ package com.android.providers.media.tools.photopickerv2.pickerchoice +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED +import android.os.Build +import android.widget.VideoView import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.paddingFromBaseline +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel import com.android.providers.media.tools.photopickerv2.R +import com.android.providers.media.tools.photopickerv2.utils.ButtonComponent +import com.android.providers.media.tools.photopickerv2.utils.SwitchComponent +import com.android.providers.media.tools.photopickerv2.utils.isImage +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage /** * This is the screen for the PickerChoice tab. */ +@OptIn(ExperimentalGlideComposeApi::class) @Composable -fun PickerChoiceScreen() { - Column ( - modifier = Modifier.fillMaxSize() - ){ +fun PickerChoiceScreen(pickerChoiceViewModel: PickerChoiceViewModel = viewModel()) { + // When VERSION.SDK_INT is lower than VERSION U, then PickerChoice will not work on the device + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // Error message when the device's version is lower than Version U Text( - text = stringResource(id = R.string.tab_pickerchoice), + text = stringResource(id = R.string.picker_choice_unsupported), fontWeight = FontWeight.Bold, - fontSize = 25.sp, - modifier = Modifier.padding(16.dp) + fontSize = 17.sp, + modifier = Modifier.padding(20.dp) + .paddingFromBaseline(40.dp), + color = Color.Red ) - Row(modifier = Modifier - .fillMaxWidth() - .padding(vertical = 100.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + } else { + val context = LocalContext.current + + var requestPermissionForImagesOnly by remember { mutableStateOf(false) } + var requestPermissionForVideosOnly by remember { mutableStateOf(false) } + var requestPermissionForBoth by remember { mutableStateOf(false) } + + val showLatestSelectionOnly by pickerChoiceViewModel + .latestSelectionOnly.observeAsState(false) + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + val allGranted = permissions.values.all { it } + val partialGranted = permissions[READ_MEDIA_VISUAL_USER_SELECTED] == true || + requestPermissionForImagesOnly || + requestPermissionForVideosOnly + if (allGranted || partialGranted) { + pickerChoiceViewModel.checkPermissions(context.contentResolver) + } else { + Toast.makeText(context, "Permissions not granted", Toast.LENGTH_SHORT).show() + } + } + + fun resetPermissions() { + requestPermissionForImagesOnly = false + requestPermissionForVideosOnly = false + requestPermissionForBoth = false + } + + Column( + modifier = Modifier.run { + padding(16.dp) + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + } ){ Text( - text = stringResource(id = R.string.working_on_it), - fontWeight = FontWeight.Medium, - fontSize = 40.sp, + text = stringResource(id = R.string.tab_pickerchoice), + fontWeight = FontWeight.Bold, + fontSize = 25.sp, + modifier = Modifier.padding(5.dp) + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = stringResource(R.string.request_permissions_for), + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + // Request Permission for Only Images + SwitchComponent( + label = stringResource(id = R.string.images), + checked = requestPermissionForImagesOnly, + onCheckedChange = { + requestPermissionForImagesOnly = it + if (it) { + resetPermissions() + requestPermissionForImagesOnly = true + } + } + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + Column(modifier = Modifier.weight(1f)) { + // Request Permission for Only Videos + SwitchComponent( + label = stringResource(id = R.string.videos), + checked = requestPermissionForVideosOnly, + onCheckedChange = { + requestPermissionForVideosOnly = it + if (it) { + resetPermissions() + requestPermissionForVideosOnly = true + } + } + ) + } + } + + // Request Permission for Both Images and Videos + SwitchComponent( + label = stringResource(id = R.string.both_images_and_videos), + checked = requestPermissionForBoth, + onCheckedChange = { + requestPermissionForBoth = it + if (it) { + resetPermissions() + requestPermissionForBoth = true + } + } + ) + + // Switch to enable show latest selection only + SwitchComponent( + label = stringResource(id = R.string.show_latest_selection_only), + checked = showLatestSelectionOnly, + onCheckedChange = { + pickerChoiceViewModel.setLatestSelectionOnly(it) + } ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + ButtonComponent( + label = stringResource(id = R.string.request_permissions), + onClick = { + when { + requestPermissionForImagesOnly -> + pickerChoiceViewModel.requestAppPermissions(imagesOnly = true) + requestPermissionForVideosOnly -> + pickerChoiceViewModel.requestAppPermissions(videosOnly = true) + requestPermissionForBoth -> + pickerChoiceViewModel.requestAppPermissions() + } + permissionLauncher.launch( + pickerChoiceViewModel.permissionRequest.value ?: arrayOf()) + }, + enabled = requestPermissionForImagesOnly || + requestPermissionForVideosOnly || + requestPermissionForBoth, + modifier = Modifier.weight(1f) + ) + } + + val mediaList by pickerChoiceViewModel.media.observeAsState(emptyList()) + DisplayMedia(mediaList) + } + } +} + +@OptIn(ExperimentalGlideComposeApi::class) +@Composable +fun DisplayMedia(mediaList: List) { + Column { + mediaList.forEach { media -> + if (isImage(LocalContext.current, media.uri)) { + // To display image + GlideImage( + model = media.uri, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(600.dp) + .padding(top = 8.dp) + ) + } else { + AndroidView( + // To display video + factory = { ctx -> + VideoView(ctx).apply { + setVideoURI(media.uri) + start() + } + }, + modifier = Modifier + .fillMaxWidth() + .height(600.dp) + .padding(top = 8.dp) + ) + } + Spacer(modifier = Modifier.height(20.dp)) + HorizontalDivider(thickness = 6.dp) + Spacer(modifier = Modifier.height(17.dp)) } } -} \ No newline at end of file +} diff --git a/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/pickerchoice/PickerChoiceViewModel.kt b/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/pickerchoice/PickerChoiceViewModel.kt index 1bcfe28af..03e9d1791 100644 --- a/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/pickerchoice/PickerChoiceViewModel.kt +++ b/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/pickerchoice/PickerChoiceViewModel.kt @@ -15,12 +15,167 @@ */ package com.android.providers.media.tools.photopickerv2.pickerchoice -import androidx.lifecycle.ViewModel +import android.Manifest.permission.READ_MEDIA_IMAGES +import android.Manifest.permission.READ_MEDIA_VIDEO +import android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED +import android.app.Application +import android.content.ContentResolver +import android.content.ContentResolver.QUERY_ARG_SQL_SORT_ORDER +import android.content.ContentUris +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker.PERMISSION_GRANTED +import androidx.core.os.bundleOf +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * PickerChoiceViewModel is responsible for managing the state and logic - * of the PhotoPicker feature. + * of the PickerChoice feature. */ -class PickerChoiceViewModel() : ViewModel() { - // Working on it -} \ No newline at end of file +class PickerChoiceViewModel(application: Application) : AndroidViewModel(application) { + + private val _permissionRequest = MutableLiveData>() + val permissionRequest: LiveData> = _permissionRequest + + private val _media = MutableLiveData>(emptyList()) + val media: LiveData> get() = _media + + private val _latestSelectionOnly = MutableLiveData(false) + val latestSelectionOnly: LiveData get() = _latestSelectionOnly + + fun setLatestSelectionOnly(enabled: Boolean) { + _latestSelectionOnly.value = enabled + } + + /** + * Requests the necessary permissions for accessing media on the device. + * + * This method sets the appropriate permissions to request based on the + * provided parameters and the Android version. + * + * @param imagesOnly a Boolean flag indicating if only image permissions should be requested. + * @param videosOnly a Boolean flag indicating if only video permissions should be requested. + */ + fun requestAppPermissions(imagesOnly: Boolean = false, videosOnly: Boolean = false) { + when { + imagesOnly -> { + _permissionRequest.value = arrayOf(READ_MEDIA_IMAGES) + } + videosOnly -> { + _permissionRequest.value = arrayOf(READ_MEDIA_VIDEO) + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> { + _permissionRequest.value = arrayOf( + READ_MEDIA_IMAGES, + READ_MEDIA_VIDEO, + READ_MEDIA_VISUAL_USER_SELECTED + ) + } + } + } + + /** + * Checks the permissions for accessing media on the device. + * + * This method checks if the application has been granted the + * READ_MEDIA_VISUAL_USER_SELECTED permission. If the device is + * running Android 14 (UPSIDE_DOWN_CAKE) or higher and the permission + * is granted, it shows a toast indicating partial access. Otherwise, + * it shows a toast indicating access denied. + */ + @RequiresApi(Build.VERSION_CODES.R) + fun checkPermissions(contentResolver: ContentResolver) { + val context = getApplication().applicationContext + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + ContextCompat.checkSelfPermission(context, READ_MEDIA_VISUAL_USER_SELECTED) == + PERMISSION_GRANTED -> { + Toast.makeText(context, "Partial access on Android 14 or higher", + Toast.LENGTH_SHORT).show() + fetchMedia(contentResolver) + } + else -> { + Toast.makeText(context, "Access denied", Toast.LENGTH_SHORT).show() + } + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun fetchMedia(contentResolver: ContentResolver) { + viewModelScope.launch { + _media.value = getMedia(contentResolver) + } + } + + data class Media( + val uri: Uri, + val name: String, + val size: Long, + val mimeType: String, + ) + @RequiresApi(Build.VERSION_CODES.R) + private suspend fun getMedia( + contentResolver: ContentResolver + ): List = withContext(Dispatchers.IO) { + val projection = arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.SIZE, + MediaStore.MediaColumns.MIME_TYPE, + ) + + val collectionUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + } else { + MediaStore.Files.getContentUri("external") + } + + val mediaList = mutableListOf() + + // TODO: BuildCompat.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 12 + val queryArgs = if (Build.VERSION.SDK_INT > Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + latestSelectionOnly.value == true + ) { + bundleOf( + QUERY_ARG_SQL_SORT_ORDER to "${MediaStore.MediaColumns.DATE_ADDED} DESC", + "android:query-arg-latest-selection-only" to true + ) + } else { + null + } + + contentResolver.query( + collectionUri, + projection, + queryArgs, + null + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + val displayNameColumn = cursor.getColumnIndexOrThrow( + MediaStore.MediaColumns.DISPLAY_NAME) + val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE) + val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE) + + while (cursor.moveToNext()) { + val uri = ContentUris.withAppendedId(collectionUri, cursor.getLong(idColumn)) + val name = cursor.getString(displayNameColumn) + val size = cursor.getLong(sizeColumn) + val mimeType = cursor.getString(mimeTypeColumn) + + val media = Media(uri, name, size, mimeType) + mediaList.add(media) + } + } + return@withContext mediaList + } +} diff --git a/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/utils/UIComponents.kt b/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/utils/UIComponents.kt index 480210fe3..56173de39 100644 --- a/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/utils/UIComponents.kt +++ b/tools/photopickerV2/src/com/android/providers/media/tools/photopickerv2/utils/UIComponents.kt @@ -158,6 +158,7 @@ fun ErrorMessage( * @param onClick the callback function to be called when the button component is clicked. * @param modifier the modifier to be applied to the button component. * @param colors the color of the button. + * @param enabled the enabled state of the button component. */ @Composable fun ButtonComponent( @@ -165,11 +166,13 @@ fun ButtonComponent( onClick: () -> Unit, modifier: Modifier = Modifier, colors: ButtonColors = ButtonDefaults.buttonColors(), + enabled: Boolean = true ) { Button( onClick = onClick, colors = colors, - modifier = modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth(), + enabled = enabled ) { Text(label) } -- cgit v1.2.3-59-g8ed1b