mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 12:55:11 +01:00
Add storage-permission handling to the v3 media select screen.
This commit is contained in:
committed by
Alex Hart
parent
7705b7dec0
commit
2bf94f2d1c
@@ -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()
|
||||
}
|
||||
|
||||
+43
-3
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+105
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+236
-18
@@ -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
|
||||
}
|
||||
|
||||
+18
-4
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user