Add storage-permission handling to the v3 media select screen.

This commit is contained in:
Greyson Parrelli
2026-07-31 18:33:17 +00:00
committed by Alex Hart
parent 7705b7dec0
commit 2bf94f2d1c
14 changed files with 581 additions and 34 deletions
@@ -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()
}
@@ -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
) {
@@ -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
)
}
@@ -85,6 +85,7 @@ fun MediaSendScreen(
}
viewModel.writeStoragePermission.Content()
viewModel.readMediaPermission.Content()
MediaSendNavDisplay(
stateFlow = viewModel.state,
@@ -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<TransientMediaListParceler> List<Media> = 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<TransientMediaConstraintsParceler> MediaConstraints = MediaSendDependencies.mediaSendRepository.getMediaConstraints(),
val storiesEnabled: Boolean = CameraDependencies.isStoriesFeatureEnabled(),
@@ -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<String> = 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()
}
}
@@ -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 }
)
}
}
}
@@ -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
}
}
}
}
@@ -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 <T> 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() {
@@ -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
}
@@ -12,14 +12,28 @@ sealed interface MediaSelectScreenState {
val selectedMedia: List<Media>
/** 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<MediaFolder>,
override val selectedMedia: List<Media>
) : MediaSelectScreenState
override val selectedMedia: List<Media>,
override val mediaPermissions: MediaPermissions = MediaPermissions.FULL
) : MediaSelectScreenState {
override val hasContent: Boolean
get() = mediaFolders.isNotEmpty()
}
data class Files(
val selectedMediaFolder: MediaFolder,
val selectedMediaFolderItems: List<Media>,
override val selectedMedia: List<Media>
) : MediaSelectScreenState
override val selectedMedia: List<Media>,
override val mediaPermissions: MediaPermissions = MediaPermissions.FULL
) : MediaSelectScreenState {
override val hasContent: Boolean
get() = selectedMediaFolderItems.isNotEmpty()
}
}
@@ -0,0 +1,32 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="72dp"
android:height="73dp"
android:viewportWidth="72"
android:viewportHeight="73">
<path
android:pathData="M6.066,26.66C5.518,22.495 5.244,20.413 5.845,18.716C6.374,17.223 7.361,15.935 8.667,15.038C10.151,14.018 12.233,13.744 16.397,13.196L41.679,9.868C45.844,9.319 47.926,9.045 49.623,9.646C51.116,10.175 52.403,11.163 53.301,12.468C54.32,13.952 54.595,16.034 55.143,20.199L57.101,35.07C57.649,39.235 57.923,41.317 57.322,43.014C56.793,44.507 55.806,45.795 54.5,46.692C53.016,47.712 50.934,47.986 46.77,48.534L21.488,51.862C17.323,52.411 15.241,52.685 13.544,52.084C12.051,51.555 10.764,50.567 9.867,49.262C8.847,47.778 8.573,45.696 8.024,41.531L6.066,26.66Z"
android:fillColor="#DDE7FF"/>
<path
android:pathData="M16.136,11.717L41.548,8.372C43.575,8.105 45.188,7.892 46.502,7.828C47.848,7.761 49.014,7.839 50.124,8.232C51.915,8.866 53.46,10.052 54.537,11.618C55.203,12.588 55.581,13.694 55.865,15.012C56.142,16.298 56.354,17.91 56.621,19.938L58.596,34.939C58.863,36.966 59.076,38.578 59.14,39.893C59.207,41.239 59.129,42.405 58.736,43.515C58.101,45.306 56.916,46.851 55.35,47.928C54.38,48.594 53.274,48.972 51.956,49.256C50.67,49.533 49.057,49.745 47.03,50.012L21.618,53.358C19.591,53.625 17.979,53.837 16.665,53.902C15.318,53.968 14.153,53.89 13.043,53.497C11.251,52.863 9.707,51.678 8.63,50.111C7.963,49.141 7.586,48.035 7.302,46.718C7.025,45.431 6.812,43.819 6.545,41.792L4.57,26.79C4.304,24.763 4.091,23.151 4.026,21.836C3.96,20.49 4.038,19.324 4.431,18.215C5.065,16.423 6.251,14.878 7.817,13.802C8.787,13.135 9.893,12.758 11.21,12.474C12.497,12.196 14.109,11.984 16.136,11.717ZM11.843,15.406C10.707,15.651 10.03,15.921 9.516,16.274C8.472,16.992 7.682,18.022 7.259,19.216C7.051,19.804 6.965,20.528 7.023,21.688C7.081,22.866 7.276,24.357 7.553,26.464L9.511,41.335C9.789,43.442 9.986,44.933 10.235,46.085C10.479,47.221 10.749,47.898 11.102,48.412C11.82,49.456 12.85,50.246 14.044,50.669C14.632,50.878 15.356,50.963 16.517,50.906C17.694,50.847 19.185,50.652 21.292,50.375L46.574,47.047C48.681,46.769 50.171,46.572 51.324,46.323C52.459,46.078 53.137,45.808 53.651,45.455C54.695,44.738 55.485,43.708 55.908,42.513C56.116,41.926 56.201,41.202 56.144,40.041C56.086,38.863 55.891,37.373 55.613,35.266L53.655,20.394C53.378,18.287 53.181,16.797 52.932,15.644C52.687,14.509 52.417,13.831 52.064,13.317C51.347,12.273 50.317,11.483 49.122,11.06C48.535,10.852 47.811,10.767 46.65,10.824C45.472,10.882 43.982,11.077 41.875,11.354L16.593,14.683C14.486,14.96 12.996,15.158 11.843,15.406Z"
android:fillColor="#6C7B9D"
android:fillType="evenOdd"/>
<path
android:pathData="M54,23.301C58.2,23.301 60.301,23.301 61.905,24.118C63.316,24.837 64.464,25.985 65.183,27.396C66,29 66,31.1 66,35.301V50.301C66,54.501 66,56.601 65.183,58.206C64.464,59.617 63.316,60.764 61.905,61.483C60.301,62.301 58.2,62.301 54,62.301H28.5C24.3,62.301 22.199,62.301 20.595,61.483C19.184,60.764 18.037,59.617 17.317,58.206C16.5,56.601 16.5,54.501 16.5,50.301L16.5,35.301C16.5,31.1 16.5,29 17.317,27.396C18.037,25.985 19.184,24.837 20.595,24.118C22.199,23.301 24.3,23.301 28.5,23.301L54,23.301Z"
android:fillColor="#FCAF68"/>
<path
android:pathData="M63.064,24.85C63.947,25.526 64.672,26.395 65.183,27.396C66,29 66,31.1 66,35.301V50.301C66,54.501 66,56.601 65.183,58.206C64.464,59.617 63.316,60.764 61.905,61.483C60.301,62.301 58.2,62.301 54,62.301H28.5C24.3,62.301 22.199,62.301 20.595,61.483C19.184,60.764 18.037,59.617 17.317,58.206C16.5,56.601 16.5,54.501 16.5,50.301V35.386C29.52,28.623 44.314,24.801 60,24.801C61.025,24.801 62.047,24.817 63.064,24.85Z"
android:fillColor="#FFCF71"/>
<path
android:pathData="M65.999,34.008C66,34.416 66,34.846 66,35.301V50.301C66,54.501 66,56.601 65.183,58.206C64.464,59.617 63.316,60.764 61.905,61.483C60.301,62.301 58.2,62.301 54,62.301L28.5,62.301C24.3,62.301 22.199,62.301 20.595,61.483C19.184,60.764 18.037,59.617 17.317,58.206C16.5,56.601 16.5,54.501 16.5,50.301V45.678C29.244,38.132 44.116,33.801 60,33.801C62.017,33.801 64.017,33.871 65.999,34.008Z"
android:fillColor="#FFE3A5"/>
<path
android:pathData="M28.5,62.301H54.19C58.264,62.3 60.325,62.288 61.905,61.483C63.316,60.764 64.463,59.617 65.182,58.206C65.919,56.759 65.992,54.91 65.999,51.482L54.979,40.128C53.211,38.306 50.288,38.306 48.521,40.128L38.182,50.78L33.624,46.14C31.834,44.317 28.887,44.349 27.136,46.21L16.897,57.094C17.005,57.498 17.143,57.863 17.317,58.206C18.036,59.617 19.184,60.764 20.595,61.483C22.199,62.301 24.299,62.301 28.5,62.301Z"
android:fillColor="#8997B5"/>
<path
android:pathData="M17.536,58.604C17.459,58.474 17.386,58.341 17.317,58.206C16.908,57.402 16.704,56.474 16.602,55.22L26.044,45.182C28.378,42.701 32.307,42.659 34.694,45.089L38.175,48.633L47.444,39.083C49.801,36.655 53.698,36.655 56.055,39.083L66,49.329V50.301C66,51.586 66,52.674 65.977,53.612L53.902,41.172C52.724,39.958 50.775,39.958 49.597,41.172L29.09,62.301H28.5C27.966,62.301 27.467,62.301 26.998,62.299L25.923,61.256L36.085,50.786L32.554,47.191C31.36,45.976 29.396,45.997 28.229,47.238L17.536,58.604Z"
android:fillColor="#4C5876"/>
<path
android:pathData="M67.5,35.235L67.5,50.366C67.5,52.411 67.5,54.037 67.393,55.349C67.283,56.692 67.053,57.838 66.519,58.887C65.656,60.58 64.279,61.957 62.586,62.82C61.537,63.354 60.391,63.584 59.048,63.694C57.736,63.801 56.11,63.801 54.066,63.801L28.434,63.801C26.39,63.801 24.764,63.801 23.452,63.694C22.108,63.584 20.963,63.354 19.914,62.82C18.221,61.957 16.844,60.58 15.981,58.887C15.446,57.838 15.217,56.692 15.107,55.349C15,54.037 15,52.411 15,50.367L15,35.235C15,33.191 15,31.565 15.107,30.253C15.217,28.909 15.446,27.764 15.981,26.715C16.844,25.021 18.221,23.645 19.914,22.782C20.963,22.247 22.108,22.018 23.452,21.908C24.764,21.801 26.39,21.801 28.434,21.801L54.066,21.801C56.11,21.801 57.736,21.801 59.048,21.908C60.391,22.018 61.537,22.247 62.586,22.782C64.279,23.645 65.656,25.021 66.519,26.715C67.053,27.764 67.283,28.909 67.393,30.253C67.5,31.565 67.5,33.191 67.5,35.235ZM64.403,30.497C64.308,29.339 64.129,28.632 63.846,28.077C63.271,26.948 62.353,26.03 61.224,25.455C60.668,25.172 59.962,24.993 58.804,24.898C57.628,24.802 56.125,24.801 54,24.801L28.5,24.801C26.375,24.801 24.872,24.802 23.696,24.898C22.538,24.993 21.831,25.172 21.276,25.455C20.147,26.03 19.229,26.948 18.654,28.077C18.371,28.632 18.192,29.339 18.097,30.497C18.001,31.672 18,33.176 18,35.301L18,50.301C18,52.426 18.001,53.929 18.097,55.105C18.192,56.263 18.371,56.969 18.654,57.525C19.229,58.654 20.147,59.572 21.276,60.147C21.831,60.43 22.538,60.609 23.696,60.704C24.872,60.8 26.375,60.801 28.5,60.801L54,60.801C56.125,60.801 57.628,60.8 58.804,60.704C59.962,60.609 60.668,60.43 61.224,60.147C62.353,59.572 63.271,58.654 63.846,57.525C64.129,56.969 64.308,56.263 64.403,55.105C64.499,53.929 64.5,52.426 64.5,50.301L64.5,35.301C64.5,33.176 64.499,31.672 64.403,30.497Z"
android:fillColor="#4F5C7D"
android:fillType="evenOdd"/>
</vector>
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9,11.875a1.5,1.5 0,1 1,3 0,1.5 1.5,0 0,1 -3,0Z"
android:fillColor="#000"/>
<path
android:pathData="m18.748,6.316 l0.107,0.812c0.32,0.004 0.605,0.013 0.858,0.034 0.471,0.038 0.908,0.121 1.32,0.33a3.38,3.38 0,0 1,1.474 1.476c0.21,0.411 0.293,0.848 0.331,1.319 0.037,0.452 0.037,1.008 0.037,1.677v4.572c0,0.67 0,1.224 -0.037,1.677 -0.038,0.471 -0.121,0.908 -0.33,1.32a3.374,3.374 0,0 1,-1.476 1.474c-0.411,0.21 -0.848,0.293 -1.319,0.331 -0.452,0.037 -1.008,0.037 -1.677,0.037L9.964,21.375c-0.67,0 -1.225,0 -1.677,-0.037 -0.471,-0.038 -0.908,-0.121 -1.32,-0.33a3.374,3.374 0,0 1,-1.474 -1.476c-0.21,-0.411 -0.293,-0.848 -0.331,-1.319a15.915,15.915 0,0 1,-0.036 -1.187,3.101 3.101,0 0,1 -1.034,-0.164 3.375,3.375 0,0 1,-1.655 -1.27c-0.262,-0.38 -0.4,-0.802 -0.5,-1.264 -0.096,-0.444 -0.168,-0.994 -0.256,-1.658L1.15,8.633C1.062,7.969 0.99,7.419 0.967,6.965c-0.023,-0.472 0.002,-0.916 0.156,-1.35a3.375,3.375 0,0 1,1.27 -1.656c0.38,-0.261 0.803,-0.4 1.265,-0.5 0.444,-0.096 0.994,-0.168 1.658,-0.256l8.002,-1.053c0.664,-0.088 1.215,-0.16 1.668,-0.182 0.472,-0.024 0.916,0.001 1.351,0.156a3.375,3.375 0,0 1,1.655 1.27c0.261,0.38 0.4,0.802 0.5,1.264 0.096,0.444 0.168,0.994 0.256,1.658ZM5.125,15.276a1.355,1.355 0,0 1,-0.45 -0.063,1.625 1.625,0 0,1 -0.796,-0.612c-0.078,-0.114 -0.155,-0.286 -0.232,-0.642 -0.079,-0.366 -0.142,-0.845 -0.236,-1.553L2.89,8.44c-0.093,-0.708 -0.155,-1.188 -0.174,-1.561 -0.018,-0.364 0.012,-0.55 0.058,-0.68 0.114,-0.324 0.328,-0.603 0.611,-0.797 0.114,-0.079 0.287,-0.156 0.643,-0.232 0.365,-0.08 0.844,-0.143 1.553,-0.236L13.51,3.89c0.709,-0.094 1.188,-0.156 1.562,-0.175 0.363,-0.017 0.55,0.012 0.68,0.058 0.323,0.115 0.602,0.329 0.797,0.612 0.078,0.114 0.155,0.286 0.232,0.642 0.078,0.366 0.142,0.845 0.235,1.553l0.072,0.545L9.964,7.125c-0.67,0 -1.225,0 -1.677,0.037 -0.471,0.038 -0.908,0.121 -1.32,0.33a3.375,3.375 0,0 0,-1.474 1.476c-0.21,0.411 -0.293,0.848 -0.331,1.319 -0.037,0.452 -0.037,1.008 -0.037,1.677v3.311ZM20.948,9.762c0.063,0.124 0.116,0.304 0.146,0.667 0.03,0.373 0.031,0.857 0.031,1.571v3.388l-1.946,-1.946a2.375,2.375 0,0 0,-3.358 0L13,16.262l-0.82,-0.82a2.375,2.375 0,0 0,-3.36 0l-1.94,1.942c-0.004,-0.25 -0.004,-0.54 -0.004,-0.884L6.876,12c0,-0.714 0,-1.198 0.031,-1.57 0.03,-0.364 0.083,-0.544 0.146,-0.668a1.63,1.63 0,0 1,0.71 -0.71c0.124,-0.063 0.304,-0.116 0.667,-0.146 0.373,-0.03 0.857,-0.031 1.571,-0.031h8c0.715,0 1.198,0 1.57,0.031 0.363,0.03 0.544,0.083 0.668,0.146 0.306,0.156 0.554,0.405 0.71,0.71ZM17.942,14.679 L21.108,17.846c-0.004,0.08 -0.008,0.154 -0.014,0.225 -0.03,0.362 -0.083,0.543 -0.146,0.667a1.625,1.625 0,0 1,-0.71 0.71c-0.124,0.063 -0.305,0.116 -0.667,0.146 -0.373,0.03 -0.857,0.031 -1.571,0.031h-8c-0.714,0 -1.198,0 -1.57,-0.031 -0.364,-0.03 -0.544,-0.083 -0.668,-0.146a1.628,1.628 0,0 1,-0.286 -0.186l2.582,-2.583a0.625,0.625 0,0 1,0.884 0l1.44,1.44a0.875,0.875 0,0 0,1.237 0l3.44,-3.44a0.625,0.625 0,0 1,0.883 0Z"
android:fillColor="#000"
android:fillType="evenOdd"/>
</vector>
@@ -48,6 +48,26 @@
<string name="MediaSelectScreen__next">Next</string>
<!-- Accessibility description for the button that leaves media selection and opens the camera. -->
<string name="MediaSelectScreen__go_to_camera">Go to camera</string>
<!-- Message shown in place of the gallery when the user has not granted permission to read their photos and videos. -->
<string name="MediaSelectScreen__signal_needs_permission_to_show_your_photos_and_videos">Signal needs permission to show your photos and videos</string>
<!-- Label for the button that asks the system for permission to read the user\'s photos and videos. -->
<string name="MediaSelectScreen__allow_access">Allow Access</string>
<!-- Message shown in place of the gallery when the user only shared selected photos with Signal and none of them can be shown. -->
<string name="MediaSelectScreen__no_photos_or_videos_found">No photos or videos found. Signal only has access to photos and videos you selected.</string>
<!-- Reminder shown above the gallery when the user only shared selected photos with Signal. -->
<string name="MediaSelectScreen__signal_has_limited_access">Signal has limited access to photos or videos</string>
<!-- Label for the button that opens the menu for changing which photos and videos Signal can see. -->
<string name="MediaSelectScreen__manage">Manage</string>
<!-- Menu option that re-opens the system picker so the user can share more photos and videos with Signal. -->
<string name="MediaSelectScreen__select_more_photos">Select more photos</string>
<!-- Menu option that opens Signal\'s permission settings in the system settings app. -->
<string name="MediaSelectScreen__go_to_settings">Go to Settings</string>
<!-- Title of the sheet shown when the permission to read photos and videos has been permanently denied, directing the user to app settings. -->
<string name="MediaSelectScreen__allow_access_to_storage">Allow access to storage</string>
<!-- Subtitle of the sheet shown when the permission to read photos and videos has been permanently denied, introducing the steps that follow. -->
<string name="MediaSelectScreen__to_show_photos_and_videos">To show photos and videos:</string>
<!-- Snackbar shown when the user denies the permission to read their photos and videos. -->
<string name="MediaSelectScreen__signal_needs_access_to_show_your_photos_and_videos">Signal needs access to show your photos and videos.</string>
<!-- Label for the button that switches the capture screen to the camera. -->
<string name="MediaCaptureScreen__camera">Camera</string>