From 2bf94f2d1c9fe78fd2c59f6dd9fb17a267870bd0 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 31 Jul 2026 18:33:17 +0000 Subject: [PATCH] Add storage-permission handling to the v3 media select screen. --- .../signal/core/ui/compose/BottomSheets.kt | 3 +- .../PermissionDeniedBottomSheet.kt | 46 +++- .../signal/mediasend/MediaSendNavDisplay.kt | 10 +- .../org/signal/mediasend/MediaSendScreen.kt | 1 + .../org/signal/mediasend/MediaSendState.kt | 7 + .../signal/mediasend/MediaSendViewModel.kt | 56 +++- .../select/MediaPermissionController.kt | 105 ++++++++ .../mediasend/select/MediaPermissions.kt | 37 +++ .../mediasend/select/MediaSelectScreen.kt | 254 ++++++++++++++++-- .../select/MediaSelectScreenEvent.kt | 9 + .../select/MediaSelectScreenState.kt | 22 +- .../main/res/drawable/permission_gallery.xml | 32 +++ .../res/drawable/symbol_album_tilt_24.xml | 13 + .../src/main/res/values/strings.xml | 20 ++ 14 files changed, 581 insertions(+), 34 deletions(-) create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissionController.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissions.kt create mode 100644 feature/media-send/src/main/res/drawable/permission_gallery.xml create mode 100644 feature/media-send/src/main/res/drawable/symbol_album_tilt_24.xml diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt b/core/ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt index 73dc9df8dc..f15bd97c20 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/BottomSheets.kt @@ -32,13 +32,14 @@ object BottomSheets { onDismissRequest: () -> Unit, sheetState: SheetState = rememberModalBottomSheetState(), properties: ModalBottomSheetProperties = ModalBottomSheetProperties(), + dragHandle: @Composable (() -> Unit)? = { Handle() }, content: @Composable ColumnScope.() -> Unit ) { return ModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, properties = properties, - dragHandle = { Handle() } + dragHandle = dragHandle ) { content() } diff --git a/core/ui/src/main/java/org/signal/core/ui/permissions/PermissionDeniedBottomSheet.kt b/core/ui/src/main/java/org/signal/core/ui/permissions/PermissionDeniedBottomSheet.kt index 1bab45904e..7ab59d1987 100644 --- a/core/ui/src/main/java/org/signal/core/ui/permissions/PermissionDeniedBottomSheet.kt +++ b/core/ui/src/main/java/org/signal/core/ui/permissions/PermissionDeniedBottomSheet.kt @@ -8,6 +8,7 @@ package org.signal.core.ui.permissions import android.content.Intent import android.net.Uri import android.provider.Settings +import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -15,13 +16,17 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.Placeholder @@ -37,6 +42,7 @@ import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.dismissWithAnimation private const val PLACEHOLDER = "__RADIO_BUTTON_PLACEHOLDER__" @@ -85,6 +91,40 @@ class PermissionDeniedBottomSheet private constructor() : ComposeBottomSheetDial } } +/** + * Compose-native counterpart to [PermissionDeniedBottomSheet], for callers that live in a composition rather than + * a fragment. Renders the same [PermissionDeniedSheetContent], so the two routes cannot drift apart. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PermissionDeniedSheet( + @StringRes titleRes: Int, + @StringRes subtitleRes: Int, + useExtended: Boolean = false, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + + // The content draws its own handle, so the sheet does not add a second one. + BottomSheets.BottomSheet( + onDismissRequest = { sheetState.dismissWithAnimation(scope, onComplete = onDismiss) }, + sheetState = sheetState, + dragHandle = null + ) { + PermissionDeniedSheetContent( + titleRes = titleRes, + subtitleRes = subtitleRes, + useExtended = useExtended, + onSettingsClicked = { + context.startActivity(Permissions.getApplicationSettingsIntent(context)) + sheetState.dismissWithAnimation(scope, onComplete = onDismiss) + } + ) + } +} + @DayNightPreviews @Composable private fun PermissionDeniedSheetContentPreview() { @@ -98,9 +138,9 @@ private fun PermissionDeniedSheetContentPreview() { } @Composable -private fun PermissionDeniedSheetContent( - titleRes: Int, - subtitleRes: Int, +fun PermissionDeniedSheetContent( + @StringRes titleRes: Int, + @StringRes subtitleRes: Int, useExtended: Boolean = false, onSettingsClicked: () -> Unit ) { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt index 241f34ab32..d4fa52034a 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt @@ -72,10 +72,11 @@ internal fun MediaSendNavDisplay( MediaSendNavKey.Select.Folders -> NavEntry(key) { val state by stateFlow.collectAsStateWithLifecycle() - val screenState = remember(state.mediaFolders, state.selectedMedia) { + val screenState = remember(state.mediaFolders, state.selectedMedia, state.mediaPermissions) { MediaSelectScreenState.Folders( mediaFolders = state.mediaFolders, - selectedMedia = state.selectedMedia + selectedMedia = state.selectedMedia, + mediaPermissions = state.mediaPermissions ) } @@ -87,11 +88,12 @@ internal fun MediaSendNavDisplay( is MediaSendNavKey.Select.Files -> NavEntry(key) { val state by stateFlow.collectAsStateWithLifecycle() - val screenState = remember(state.selectedMedia, state.selectedMediaFolderItems) { + val screenState = remember(state.selectedMedia, state.selectedMediaFolderItems, state.mediaPermissions) { MediaSelectScreenState.Files( selectedMediaFolder = key.folder, selectedMediaFolderItems = state.selectedMediaFolderItems, - selectedMedia = state.selectedMedia + selectedMedia = state.selectedMedia, + mediaPermissions = state.mediaPermissions ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt index 985b2e17cf..59572ede07 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt @@ -85,6 +85,7 @@ fun MediaSendScreen( } viewModel.writeStoragePermission.Content() + viewModel.readMediaPermission.Content() MediaSendNavDisplay( stateFlow = viewModel.state, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt index 5682d0b1bf..7932ec344e 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt @@ -18,6 +18,7 @@ import org.signal.core.models.parcelers.NullableCharSequenceParceler import org.signal.core.util.ContentTypeUtil import org.signal.mediasend.edit.image.BrushWidths import org.signal.mediasend.edit.video.VideoTrimData +import org.signal.mediasend.select.MediaPermissions import kotlin.time.Duration /** @@ -129,6 +130,12 @@ data class MediaSendState( */ val selectedMediaFolderItems: @WriteWith List = emptyList(), + /** + * How much of the device's media the user currently lets us read. Re-derived alongside [mediaFolders], since a + * permission granted while we were backgrounded is only observable by looking again. + */ + val mediaPermissions: MediaPermissions = MediaPermissions.FULL, + val mediaConstraints: @WriteWith MediaConstraints = MediaSendDependencies.mediaSendRepository.getMediaConstraints(), val storiesEnabled: Boolean = CameraDependencies.isStoriesFeatureEnabled(), diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index d48c4397ba..f5646bda42 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -42,6 +42,7 @@ import org.signal.core.models.media.MediaFolder import org.signal.core.ui.compose.DialogController import org.signal.core.ui.compose.DialogResult import org.signal.core.ui.compose.PermissionController +import org.signal.core.ui.compose.Snackbars import org.signal.core.ui.util.StorageUtil import org.signal.core.util.ContentTypeUtil import org.signal.core.util.StringUtil @@ -61,6 +62,8 @@ import org.signal.mediasend.edit.image.BrushWidthsState import org.signal.mediasend.edit.video.VideoTrimData import org.signal.mediasend.preupload.PreUploadController import org.signal.mediasend.preupload.PreUploadResult +import org.signal.mediasend.select.MediaPermissionController +import org.signal.mediasend.select.MediaPermissions import org.signal.mediasend.select.MediaSelectScreenEvent import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants import java.io.FileInputStream @@ -131,6 +134,8 @@ class MediaSendViewModel( permanentDenialMessage = R.string.MediaSendViewModel__signal_needs_the_storage_permission ) + internal val readMediaPermission = MediaPermissionController() + private val qrCheckRequest: Channel = Channel(Channel.RENDEZVOUS) /** @@ -223,14 +228,33 @@ class MediaSendViewModel( //region Media Selection + /** + * Re-reads the gallery from the media store, along with the level of access we currently have. The contents of + * the selected folder are re-read too, since widening selected-photos access adds items to it without changing + * the folder itself. + */ fun refreshMediaFolders() { viewModelScope.launch { + val mediaPermissions = MediaPermissions.current() val folders = repository.getFolders() - internalState.update { - it.copy( + + val reloadedFolder = internalState.value.selectedMediaFolder?.takeIf { it in folders } + val reloadedItems = reloadedFolder?.let { repository.getMedia(it.bucketId) } + + internalState.update { current -> + // The folder can be picked while we are still loading, so what we re-read is only applied if it is still + // what is on screen. Anything else keeps whatever the folder click itself put there. + val selectedFolder = current.selectedMediaFolder?.takeIf { it in folders } + + current.copy( + mediaPermissions = mediaPermissions, mediaFolders = folders, - selectedMediaFolder = if (it.selectedMediaFolder in folders) it.selectedMediaFolder else null, - selectedMediaFolderItems = if (it.selectedMediaFolder in folders) it.selectedMediaFolderItems else emptyList() + selectedMediaFolder = selectedFolder, + selectedMediaFolderItems = when { + selectedFolder == null -> emptyList() + selectedFolder == reloadedFolder && reloadedItems != null -> reloadedItems + else -> current.selectedMediaFolderItems + } ) } } @@ -244,6 +268,30 @@ class MediaSendViewModel( is MediaSelectScreenEvent.ReorderSelectedMedia -> reorderMedia(mediaSelectScreenEvent.fromIndex, mediaSelectScreenEvent.toIndex) MediaSelectScreenEvent.NavigateToEdit -> backStack.goToEdit() MediaSelectScreenEvent.NavigateToCamera -> backStack.goToCamera() + MediaSelectScreenEvent.Refresh -> refreshMediaFolders() + MediaSelectScreenEvent.RequestMediaPermissions -> requestReadMediaPermissions(reportDenial = true) + MediaSelectScreenEvent.SelectMorePhotos -> requestReadMediaPermissions(reportDenial = false) + } + } + + /** + * Prompts for the gallery's read permissions and refreshes on any result, granted or not: selected-photos access + * comes back as a denial of the broad permissions but still changes what we can see. + */ + private fun requestReadMediaPermissions(reportDenial: Boolean) { + viewModelScope.launch { + val denied = readMediaPermission.request(permanentDenialSheet = reportDenial) + + if (reportDenial && denied) { + internalSnackbarEvents.trySend( + SnackbarEvent( + message = R.string.MediaSelectScreen__signal_needs_access_to_show_your_photos_and_videos, + duration = Snackbars.Duration.LONG + ) + ) + } + + refreshMediaFolders() } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissionController.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissionController.kt new file mode 100644 index 0000000000..4fa8498e01 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissionController.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.select + +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.core.app.ActivityCompat +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import org.signal.core.ui.permissions.PermissionDeniedSheet +import org.signal.core.ui.util.StorageUtil +import org.signal.core.util.permissions.PermissionCompat +import org.signal.mediasend.R + +/** + * Requests the read permissions the gallery needs from a coroutine: [request] suspends until the user resolves the + * system prompt. The gallery's counterpart to [org.signal.core.ui.compose.PermissionController], which only speaks + * for a single permission and treats "all granted" as the only success. + * + * The image and video permissions have to be requested as a set, and on Android 14+ the user can answer with + * selected-photos access, which comes back as a denial of the broad permissions even though we gained access. So + * [request] does not report what was granted: callers re-read what they can actually see via + * [MediaPermissions.current], matching the `onAnyResult` handling in the v2 gallery. + * + * Hold an instance where a coroutine scope is available (typically a `ViewModel`) and render [Content] once in your + * composition. [request] is single-shot: drive one request at a time per instance. + */ +@Stable +internal class MediaPermissionController { + + private var requestId: Int by mutableIntStateOf(0) + private var denied: Boolean? by mutableStateOf(null) + private var showSheetOnPermanentDenial: Boolean = false + private var launchedRequestId: Int = 0 + + /** + * Prompts for the image and video read permissions and suspends until the user resolves the prompt. + * + * @param permanentDenialSheet offer a route into app settings if the system will no longer prompt. Only wanted for + * the up-front ask, since a user who already granted selected-photos access has a working path back. + * @return whether the answer left us without the access we asked for, so the caller can say so. Selected-photos + * access is not a denial: it grants `READ_MEDIA_VISUAL_USER_SELECTED`, which is the only permission that counts + * towards a denial on Android 14+. + */ + suspend fun request(permanentDenialSheet: Boolean): Boolean { + showSheetOnPermanentDenial = permanentDenialSheet + denied = null + requestId++ + return snapshotFlow { denied } + .filterNotNull() + .first() + } + + /** Hosts the launcher [request] drives. Call once in composition. */ + @OptIn(ExperimentalPermissionsApi::class) + @Composable + fun Content() { + val activity = LocalActivity.current + var showPermanentDenialSheet by rememberSaveable { mutableStateOf(false) } + val permissions = remember { PermissionCompat.forImagesAndVideos().toList() } + + val permissionsState = rememberMultiplePermissionsState(permissions) { results -> + // Driven off what we can actually read rather than the result map: selected-photos access denies the broad + // permissions, and reporting that as a permanent denial would send a user who just granted access to settings. + val stillLocked = !StorageUtil.canReadAnyFromMediaStore() + val willNotPromptAgain = activity != null && permissions.none { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) } + + showPermanentDenialSheet = showSheetOnPermanentDenial && stillLocked && willNotPromptAgain + denied = PermissionCompat.getRequiredPermissionsForDenial().all { results[it] == false } + } + + // Guarded on launchedRequestId so a new composition cannot re-prompt for a request that has already been fired. + LaunchedEffect(requestId) { + if (requestId == 0 || requestId == launchedRequestId) { + return@LaunchedEffect + } + launchedRequestId = requestId + + permissionsState.launchMultiplePermissionRequest() + } + + if (showPermanentDenialSheet) { + PermissionDeniedSheet( + titleRes = R.string.MediaSelectScreen__allow_access_to_storage, + subtitleRes = R.string.MediaSelectScreen__to_show_photos_and_videos, + useExtended = true, + onDismiss = { showPermanentDenialSheet = false } + ) + } + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissions.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissions.kt new file mode 100644 index 0000000000..535adc4645 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissions.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.select + +import org.signal.core.ui.util.StorageUtil + +/** + * How much of the device's media store the user has let us read. Drives what the select screen can show and + * which call to action, if any, it puts in front of the user. + */ +enum class MediaPermissions { + /** No read access at all. There is nothing to show, so we have to ask. */ + NONE, + + /** Android 14+ selected-photos access. We only see the items the user picked out for us. */ + PARTIAL, + + /** Full read access to the media store. */ + FULL; + + companion object { + /** + * Reads the current level of access. Selected-photos access also satisfies + * [StorageUtil.canReadAnyFromMediaStore], so it has to be checked first. + */ + fun current(): MediaPermissions { + return when { + StorageUtil.canOnlyReadSelectedMediaStore() -> PARTIAL + StorageUtil.canReadAnyFromMediaStore() -> FULL + else -> NONE + } + } + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt index eefdb55d98..44b1e11d48 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -47,26 +49,32 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.window.core.layout.WindowSizeClass import org.signal.core.models.media.Media import org.signal.core.models.media.MediaFolder import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.DropdownMenus import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.SignalIcons @@ -79,6 +87,10 @@ import org.signal.glide.compose.GlideImage import org.signal.mediasend.MediaSendMetrics import org.signal.mediasend.R import org.signal.mediasend.edit.rememberPreviewMedia +import org.signal.core.ui.permissions.Permissions as PermissionsUtil + +/** How many empty tiles stand in for the gallery we are not allowed to show. Matches the v2 gallery. */ +private const val PLACEHOLDER_COUNT = 100 /** * Allows user to select one or more pieces of content to add to the @@ -89,9 +101,21 @@ internal fun MediaSelectScreen( state: MediaSelectScreenState, onEvent: (MediaSelectScreenEvent) -> Unit ) { - val gridConfiguration = rememberGridConfiguration(state is MediaSelectScreenState.Folders) + // Without read access there is nothing to browse, and with selected-photos access and nothing selected there is + // nothing yet. Both show the placeholder grid behind a call to action, so both use the denser file grid. + val showPlaceholders = state.mediaPermissions == MediaPermissions.NONE || + (state.mediaPermissions == MediaPermissions.PARTIAL && !state.hasContent) + + val gridConfiguration = rememberGridConfiguration(state is MediaSelectScreenState.Folders && !showPlaceholders) val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + // The system prompt and the app settings round-trip both land us back here, and neither tells us what changed. + val currentOnEvent by rememberUpdatedState(onEvent) + LifecycleResumeEffect(Unit) { + currentOnEvent(MediaSelectScreenEvent.Refresh) + onPauseOrDispose { } + } + Scaffolds.Settings( title = when (state) { is MediaSelectScreenState.Folders -> stringResource(R.string.MediaSelectScreen__gallery) @@ -112,27 +136,50 @@ internal fun MediaSelectScreen( .padding(paddingValues) .fillMaxSize() ) { - LazyVerticalGrid( - columns = gridConfiguration.gridCells, - horizontalArrangement = spacedBy(gridConfiguration.horizontalSpacing), - verticalArrangement = spacedBy(gridConfiguration.verticalSpacing), - modifier = Modifier - .padding(horizontal = gridConfiguration.horizontalMargin) - .weight(1f) - ) { - when (state) { - is MediaSelectScreenState.Folders -> { - items(state.mediaFolders, key = { it.bucketId }) { - MediaFolderTile(it, onEvent) - } - } + // Selected-photos access that has something to show gets a persistent reminder rather than an interstitial, + // so the user can keep browsing what they already shared while still having a way to share more. + if (state.mediaPermissions == MediaPermissions.PARTIAL && state.hasContent) { + LimitedAccessBar(onEvent) + } - is MediaSelectScreenState.Files -> { - items(state.selectedMediaFolderItems, key = { it.uri }) { media -> - MediaTile(media = media, state.selectedMedia.indexOfFirst { it.uri == media.uri }, onEvent = onEvent) + Box(modifier = Modifier.weight(1f)) { + LazyVerticalGrid( + columns = gridConfiguration.gridCells, + horizontalArrangement = spacedBy(gridConfiguration.horizontalSpacing), + verticalArrangement = spacedBy(gridConfiguration.verticalSpacing), + userScrollEnabled = !showPlaceholders, + modifier = Modifier + .padding(horizontal = gridConfiguration.horizontalMargin) + .fillMaxSize() + ) { + if (showPlaceholders) { + items(PLACEHOLDER_COUNT) { + MediaTilePlaceholder() + } + } else { + when (state) { + is MediaSelectScreenState.Folders -> { + items(state.mediaFolders, key = { it.bucketId }) { + MediaFolderTile(it, onEvent) + } + } + + is MediaSelectScreenState.Files -> { + items(state.selectedMediaFolderItems, key = { it.uri }) { media -> + MediaTile(media = media, state.selectedMedia.indexOfFirst { it.uri == media.uri }, onEvent = onEvent) + } + } } } } + + if (showPlaceholders) { + MediaAccessCallToAction( + mediaPermissions = state.mediaPermissions, + onEvent = onEvent, + modifier = Modifier.align(Alignment.Center) + ) + } } AnimatedVisibility( @@ -247,6 +294,132 @@ private fun WindowSizeClass.forWidthBreakpoint( } } +/** + * Persistent reminder that we are only seeing the media the user handed us, shown above the grid whenever + * selected-photos access has produced something to browse. + */ +@Composable +private fun LimitedAccessBar(onEvent: (MediaSelectScreenEvent) -> Unit) { + val menuController = remember { DropdownMenus.MenuController() } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 72.dp) + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.MediaSelectScreen__signal_has_limited_access), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp) + ) + + Box { + Buttons.MediumTonal(onClick = menuController::toggle) { + Text(text = stringResource(R.string.MediaSelectScreen__manage)) + } + + ManageAccessMenu(menuController = menuController, onEvent = onEvent) + } + } +} + +/** + * Shown over the placeholder grid when we have nothing of the user's to display: either we cannot read their media + * at all and have to ask, or they granted selected-photos access without sharing anything we can use. + */ +@Composable +private fun MediaAccessCallToAction( + mediaPermissions: MediaPermissions, + onEvent: (MediaSelectScreenEvent) -> Unit, + modifier: Modifier = Modifier +) { + val menuController = remember { DropdownMenus.MenuController() } + val hasNoAccess = mediaPermissions == MediaPermissions.NONE + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.padding(horizontal = 30.dp) + ) { + Image( + painter = painterResource(R.drawable.permission_gallery), + contentDescription = null + ) + + Text( + text = stringResource( + if (hasNoAccess) { + R.string.MediaSelectScreen__signal_needs_permission_to_show_your_photos_and_videos + } else { + R.string.MediaSelectScreen__no_photos_or_videos_found + } + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 20.dp) + ) + + Box(modifier = Modifier.padding(top = 20.dp)) { + if (hasNoAccess) { + Buttons.LargeTonal(onClick = { onEvent(MediaSelectScreenEvent.RequestMediaPermissions) }) { + Text(text = stringResource(R.string.MediaSelectScreen__allow_access)) + } + } else { + Buttons.LargeTonal(onClick = menuController::toggle) { + Text(text = stringResource(R.string.MediaSelectScreen__manage)) + } + + ManageAccessMenu(menuController = menuController, onEvent = onEvent) + } + } + } +} + +/** + * The two ways out of selected-photos access: widen the selection through the system prompt, or go turn the + * permission up in app settings. + */ +@Composable +private fun ManageAccessMenu( + menuController: DropdownMenus.MenuController, + onEvent: (MediaSelectScreenEvent) -> Unit +) { + val context = LocalContext.current + + DropdownMenus.Menu(controller = menuController, offsetX = 0.dp, offsetY = 8.dp) { controller -> + DropdownMenus.ItemWithIcon( + menuController = controller, + drawableResId = R.drawable.symbol_album_tilt_24, + stringResId = R.string.MediaSelectScreen__select_more_photos, + onClick = { onEvent(MediaSelectScreenEvent.SelectMorePhotos) } + ) + + DropdownMenus.ItemWithIcon( + menuController = controller, + drawableResId = org.signal.core.ui.R.drawable.symbol_settings_android_24, + stringResId = R.string.MediaSelectScreen__go_to_settings, + onClick = { context.startActivity(PermissionsUtil.getApplicationSettingsIntent(context)) } + ) + } +} + +/** + * Empty tile standing in for media we are not allowed to see, so the call to action reads as a gallery we could + * be looking at rather than a blank screen. + */ +@Composable +private fun MediaTilePlaceholder() { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background(color = MaterialTheme.colorScheme.surfaceVariant) + ) +} + @Composable private fun MediaFolderTile( mediaFolder: MediaFolder, @@ -490,6 +663,51 @@ private fun MediaSelectScreenMediaPreview() { } } +@AllDevicePreviews +@Composable +private fun MediaSelectScreenNoPermissionPreview() { + Previews.Preview { + MediaSelectScreen( + state = MediaSelectScreenState.Folders( + mediaFolders = emptyList(), + selectedMedia = emptyList(), + mediaPermissions = MediaPermissions.NONE + ), + onEvent = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun MediaSelectScreenPartialPermissionEmptyPreview() { + Previews.Preview { + MediaSelectScreen( + state = MediaSelectScreenState.Folders( + mediaFolders = emptyList(), + selectedMedia = emptyList(), + mediaPermissions = MediaPermissions.PARTIAL + ), + onEvent = {} + ) + } +} + +@AllDevicePreviews +@Composable +private fun MediaSelectScreenPartialPermissionPreview() { + Previews.Preview { + MediaSelectScreen( + state = MediaSelectScreenState.Folders( + mediaFolders = rememberPreviewMediaFolders(4), + selectedMedia = emptyList(), + mediaPermissions = MediaPermissions.PARTIAL + ), + onEvent = {} + ) + } +} + @DayNightPreviews @Composable private fun MediaFolderTilePreview() { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt index c4c60e543f..521436b598 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt @@ -15,4 +15,13 @@ sealed interface MediaSelectScreenEvent { data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaSelectScreenEvent data object NavigateToEdit : MediaSelectScreenEvent data object NavigateToCamera : MediaSelectScreenEvent + + /** Re-read the gallery and the current permission level, e.g. after coming back from app settings. */ + data object Refresh : MediaSelectScreenEvent + + /** The up-front "Allow access" ask, made when we cannot read anything at all. */ + data object RequestMediaPermissions : MediaSelectScreenEvent + + /** Re-ask while holding selected-photos access, so the user can widen what we can see. */ + data object SelectMorePhotos : MediaSelectScreenEvent } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenState.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenState.kt index c9bc60576c..6d9f3d47ba 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenState.kt @@ -12,14 +12,28 @@ sealed interface MediaSelectScreenState { val selectedMedia: List + /** How much of the device's media we are allowed to read. */ + val mediaPermissions: MediaPermissions + + /** Whether the media store actually gave us anything to render for this screen. */ + val hasContent: Boolean + data class Folders( val mediaFolders: List, - override val selectedMedia: List - ) : MediaSelectScreenState + override val selectedMedia: List, + override val mediaPermissions: MediaPermissions = MediaPermissions.FULL + ) : MediaSelectScreenState { + override val hasContent: Boolean + get() = mediaFolders.isNotEmpty() + } data class Files( val selectedMediaFolder: MediaFolder, val selectedMediaFolderItems: List, - override val selectedMedia: List - ) : MediaSelectScreenState + override val selectedMedia: List, + override val mediaPermissions: MediaPermissions = MediaPermissions.FULL + ) : MediaSelectScreenState { + override val hasContent: Boolean + get() = selectedMediaFolderItems.isNotEmpty() + } } diff --git a/feature/media-send/src/main/res/drawable/permission_gallery.xml b/feature/media-send/src/main/res/drawable/permission_gallery.xml new file mode 100644 index 0000000000..892e0daefc --- /dev/null +++ b/feature/media-send/src/main/res/drawable/permission_gallery.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/feature/media-send/src/main/res/drawable/symbol_album_tilt_24.xml b/feature/media-send/src/main/res/drawable/symbol_album_tilt_24.xml new file mode 100644 index 0000000000..1b01582d76 --- /dev/null +++ b/feature/media-send/src/main/res/drawable/symbol_album_tilt_24.xml @@ -0,0 +1,13 @@ + + + + diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index a92f5623a1..a64cabfa2b 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -48,6 +48,26 @@ Next Go to camera + + Signal needs permission to show your photos and videos + + Allow Access + + No photos or videos found. Signal only has access to photos and videos you selected. + + Signal has limited access to photos or videos + + Manage + + Select more photos + + Go to Settings + + Allow access to storage + + To show photos and videos: + + Signal needs access to show your photos and videos. Camera