diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e59330adc..0b64c14f585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ - Fix keyboard not closing when opening the attachments picker from `MessagesScreen`. [#5506](https://github.com/GetStream/stream-chat-android/pull/5506) ### ⬆️ Improved +- Add support for partial media access in `AttachmentsPickerImagesTabFactory` and `AttachmentsPickerFilesTabFactory` for Android 14+. [#5518](https://github.com/GetStream/stream-chat-android/pull/5518) ### ✅ Added - The `StreamAttachmentFactories.defaultFactories()` method now accepts a `skipTypes` parameter to skip specific factory types. [#5494](https://github.com/GetStream/stream-chat-android/pull/5494) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 9edeae77a0f..2ffe3cc7795 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -857,7 +857,7 @@ public final class io/getstream/chat/android/compose/ui/components/attachments/f } public final class io/getstream/chat/android/compose/ui/components/attachments/images/ImagesPickerKt { - public static final fun ImagesPicker (Ljava/util/List;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun ImagesPicker (Ljava/util/List;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/chat/android/compose/ui/components/audio/ComposableSingletons$PlaybackTimerKt { @@ -1635,6 +1635,13 @@ public final class io/getstream/chat/android/compose/ui/messages/attachments/fac public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; } +public final class io/getstream/chat/android/compose/ui/messages/attachments/factory/ComposableSingletons$NoStorageAccessContentKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/factory/ComposableSingletons$NoStorageAccessContentKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; +} + public final class io/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollCreationDiscardDialogKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollCreationDiscardDialogKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; diff --git a/stream-chat-android-compose/src/main/AndroidManifest.xml b/stream-chat-android-compose/src/main/AndroidManifest.xml index 14450cf3a62..869a2bc075f 100644 --- a/stream-chat-android-compose/src/main/AndroidManifest.xml +++ b/stream-chat-android-compose/src/main/AndroidManifest.xml @@ -20,6 +20,8 @@ + + Unit = {}, + addMoreContent: @Composable () -> Unit = { + DefaultAddMoreItem(onAddMoreClick) + }, ) { LazyVerticalGrid( modifier = modifier, columns = GridCells.Fixed(DefaultNumberOfPicturesPerRow), contentPadding = PaddingValues(1.dp), ) { + if (showAddMore) { + item { addMoreContent() } + } items(images) { imageItem -> itemContent(imageItem) } } } @@ -210,6 +226,46 @@ private fun BoxScope.VideoThumbnailOverlay( } } +/** + * Default 'pick more' tile to be shown if the user can pick more images. + * + * @param onPickMoreClick Action invoked when the user clicks on the 'pick more' tile. + */ +@Composable +internal fun DefaultAddMoreItem(onPickMoreClick: () -> Unit) { + Column( + modifier = Modifier + .height(125.dp) + .padding(2.dp) + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = ChatTheme.colors.borders, + shape = RoundedCornerShape(8.dp), + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + onClick = onPickMoreClick, + ) + .testTag("Stream_AttachmentPickerPickMore"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.stream_compose_ic_add), + contentDescription = null, + tint = ChatTheme.colors.textLowEmphasis, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.stream_ui_message_composer_permissions_visual_media_add_more), + style = ChatTheme.typography.body, + color = ChatTheme.colors.textLowEmphasis, + ) + } +} + /** * The time code of the frame to extract from a video. */ diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerFilesTabFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerFilesTabFactory.kt index 36a5bb525a2..3c1d88ff941 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerFilesTabFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerFilesTabFactory.kt @@ -16,24 +16,33 @@ package io.getstream.chat.android.compose.ui.messages.attachments.factory -import android.Manifest -import android.os.Build import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LocalLifecycleOwner import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.state.messages.attachments.AttachmentsPickerMode @@ -44,6 +53,7 @@ import io.getstream.chat.android.compose.ui.util.StorageHelperWrapper import io.getstream.chat.android.ui.common.helper.internal.AttachmentFilter import io.getstream.chat.android.ui.common.helper.internal.StorageHelper import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData +import io.getstream.chat.android.uiutils.util.openSystemSettings /** * Holds the information required to add support for "files" tab in the attachment picker. @@ -85,7 +95,6 @@ public class AttachmentsPickerFilesTabFactory : AttachmentsPickerTabFactory { * @param onAttachmentItemSelected Handler when the item selection state changes. * @param onAttachmentsSubmitted Handler to submit the selected attachments to the message composer. */ - @OptIn(ExperimentalPermissionsApi::class) @Composable override fun PickerTabContent( onAttachmentPickerAction: (AttachmentPickerAction) -> Unit, @@ -94,36 +103,38 @@ public class AttachmentsPickerFilesTabFactory : AttachmentsPickerTabFactory { onAttachmentItemSelected: (AttachmentPickerItemState) -> Unit, onAttachmentsSubmitted: (List) -> Unit, ) { - var storagePermissionRequested by rememberSaveable { mutableStateOf(false) } - val storagePermissionState = rememberMultiplePermissionsState( - permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - listOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.READ_MEDIA_AUDIO, - ) - } else { - listOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - ) - }, - ) { - storagePermissionRequested = true - } - val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val storageHelper: StorageHelperWrapper = remember { StorageHelperWrapper(context, StorageHelper(), AttachmentFilter()) } + var showPermanentlyDeniedSnackBar by remember { mutableStateOf(false) } + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + if (isPermanentlyDenied(context, result)) { + showPermanentlyDeniedSnackBar = true + } + } + val filesAccess by filesAccessAsState(context, lifecycleOwner) { value -> + if (value != FilesAccess.DENIED) { + onAttachmentsChanged( + storageHelper.getFiles().map { AttachmentPickerItemState(it, false) }, + ) + } + } - when (storagePermissionState.allPermissionsGranted) { - true -> { + // Content + FilesAccessContent( + filesAccess = filesAccess, + onRequestFilesAccess = { permissionLauncher.launch(filesPermissions()) }, + onRequestVisualMediaAccess = { permissionLauncher.launch(visualMediaPermissions()) }, + onRequestAudioAccess = { permissionLauncher.launch(audioPermissions()) }, + filePicker = { FilesPicker( files = attachments, onItemSelected = onAttachmentItemSelected, onBrowseFilesResult = { uris -> val attachments = storageHelper.getAttachmentsMetadataFromUris(uris) - // Check if some of the files were filtered out due to upload config if (uris.size != attachments.size) { Toast.makeText( @@ -136,28 +147,129 @@ public class AttachmentsPickerFilesTabFactory : AttachmentsPickerTabFactory { onAttachmentsSubmitted(attachments) }, ) - } + }, + ) - else -> { - val revokedPermissionState = storagePermissionState.revokedPermissions.first() - MissingPermissionContent(revokedPermissionState) + // Access permanently denied snackbar + val snackBarHostState = remember { SnackbarHostState() } + PermissionPermanentlyDeniedSnackBar(snackBarHostState) { + context.openSystemSettings() + } + val snackbarMessage = stringResource(id = R.string.stream_ui_message_composer_permission_setting_message) + val snackbarAction = stringResource(id = R.string.stream_ui_message_composer_permissions_setting_button) + LaunchedEffect(showPermanentlyDeniedSnackBar) { + if (showPermanentlyDeniedSnackBar) { + snackBarHostState.showSnackbar(snackbarMessage, snackbarAction) + showPermanentlyDeniedSnackBar = false } } + } - val hasPermission = storagePermissionState.allPermissionsGranted + @Composable + private fun FilesAccessContent( + filesAccess: FilesAccess, + filePicker: @Composable () -> Unit, + onRequestFilesAccess: () -> Unit, + onRequestVisualMediaAccess: () -> Unit, + onRequestAudioAccess: () -> Unit, + ) { + when (filesAccess) { + FilesAccess.DENIED -> { + NoStorageAccessContent(onRequestAccessClick = onRequestFilesAccess) + } - LaunchedEffect(storagePermissionState.allPermissionsGranted) { - if (storagePermissionState.allPermissionsGranted) { - onAttachmentsChanged( - storageHelper.getFiles().map { AttachmentPickerItemState(it, false) }, - ) + FilesAccess.PARTIAL_VISUAL -> { + Column { + GrantAudioAccessButton(onClick = onRequestAudioAccess) + AllowMoreVisualMediaButton(onClick = onRequestVisualMediaAccess) + filePicker() + } } - } - LaunchedEffect(Unit) { - if (!hasPermission && !storagePermissionRequested) { - storagePermissionState.launchMultiplePermissionRequest() + FilesAccess.FULL_VISUAL -> { + Column { + GrantAudioAccessButton(onClick = onRequestAudioAccess) + filePicker() + } } + + FilesAccess.AUDIO -> { + Column { + GrantVisualMediaAccessButton(onClick = onRequestVisualMediaAccess) + filePicker() + } + } + + FilesAccess.AUDIO_AND_PARTIAL_VISUAL -> { + Column { + AllowMoreVisualMediaButton(onClick = onRequestVisualMediaAccess) + filePicker() + } + } + + FilesAccess.AUDIO_AND_FULL_VISUAL -> { + filePicker() + } + } + } + + @Composable + private fun GrantVisualMediaAccessButton(onClick: () -> Unit) { + RequestAdditionalAccessButton( + textId = R.string.stream_ui_message_composer_permissions_files_allow_visual_media_access, + contentDescriptionId = R.string.stream_ui_message_composer_permissions_files_allow_visual_media_access, + onClick = onClick, + ) + } + + @Composable + private fun AllowMoreVisualMediaButton(onClick: () -> Unit) { + RequestAdditionalAccessButton( + textId = R.string.stream_ui_message_composer_permissions_files_allow_more_visual_media, + contentDescriptionId = R.string.stream_ui_message_composer_permissions_files_allow_more_visual_media, + onClick = onClick, + ) + } + + @Composable + private fun GrantAudioAccessButton(onClick: () -> Unit) { + RequestAdditionalAccessButton( + textId = R.string.stream_ui_message_composer_permissions_files_allow_audio_access, + contentDescriptionId = R.string.stream_ui_message_composer_permissions_files_allow_audio_access, + onClick = onClick, + ) + } + + @Composable + private fun RequestAdditionalAccessButton( + @StringRes textId: Int, + @StringRes contentDescriptionId: Int, + onClick: () -> Unit, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding(16.dp) + .weight(1f), + text = stringResource(id = textId), + style = ChatTheme.typography.bodyBold, + color = ChatTheme.colors.textHighEmphasis, + ) + + IconButton( + content = { + Icon( + painter = painterResource(id = R.drawable.stream_compose_ic_more_files), + contentDescription = stringResource(id = contentDescriptionId), + tint = ChatTheme.colors.primaryAccent, + ) + }, + onClick = onClick, + ) } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerImagesTabFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerImagesTabFactory.kt index 7bbcd1a0d4f..7dab4add684 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerImagesTabFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerImagesTabFactory.kt @@ -16,16 +16,16 @@ package io.getstream.chat.android.compose.ui.messages.attachments.factory -import android.Manifest -import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.padding import androidx.compose.material.Icon +import androidx.compose.material.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -33,8 +33,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState +import androidx.lifecycle.compose.LocalLifecycleOwner import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.state.messages.attachments.AttachmentsPickerMode @@ -45,6 +44,7 @@ import io.getstream.chat.android.compose.ui.util.StorageHelperWrapper import io.getstream.chat.android.ui.common.helper.internal.AttachmentFilter import io.getstream.chat.android.ui.common.helper.internal.StorageHelper import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData +import io.getstream.chat.android.uiutils.util.openSystemSettings /** * Holds the information required to add support for "images" tab in the attachment picker. @@ -86,7 +86,6 @@ public class AttachmentsPickerImagesTabFactory : AttachmentsPickerTabFactory { * @param onAttachmentItemSelected Handler when the item selection state changes. * @param onAttachmentsSubmitted Handler to submit the selected attachments to the message composer. */ - @OptIn(ExperimentalPermissionsApi::class) @Composable override fun PickerTabContent( onAttachmentPickerAction: (AttachmentPickerAction) -> Unit, @@ -95,60 +94,91 @@ public class AttachmentsPickerImagesTabFactory : AttachmentsPickerTabFactory { onAttachmentItemSelected: (AttachmentPickerItemState) -> Unit, onAttachmentsSubmitted: (List) -> Unit, ) { - var storagePermissionRequested by rememberSaveable { mutableStateOf(false) } - val storagePermissionState = - rememberMultiplePermissionsState( - permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - listOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - ) - } else { - listOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - ) - }, - ) { - storagePermissionRequested = true - } - + val permissions = visualMediaPermissions() val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val storageHelper: StorageHelperWrapper = remember { StorageHelperWrapper(context, StorageHelper(), AttachmentFilter()) } + val mediaAccess by visualMediaAccessAsState(context, lifecycleOwner) { value -> + if (value != VisualMediaAccess.DENIED) { + val media = storageHelper.getMedia() + val mediaAttachments = media.map { AttachmentPickerItemState(it, false) } + onAttachmentsChanged(mediaAttachments) + } + } - when (storagePermissionState.allPermissionsGranted) { - true -> { - ImagesPicker( - modifier = Modifier.padding( - top = 16.dp, - start = 2.dp, - end = 2.dp, - bottom = 2.dp, - ), - images = attachments, - onImageSelected = onAttachmentItemSelected, - ) + var showPermanentlyDeniedSnackBar by remember { mutableStateOf(false) } + + val permissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + if (isPermanentlyDenied(context, result)) { + showPermanentlyDeniedSnackBar = true + } } - else -> { - val revokedPermissionState = storagePermissionState.revokedPermissions.first() - MissingPermissionContent(revokedPermissionState) + // Content + VisualMediaAccessContent( + visualMediaAccess = mediaAccess, + attachments = attachments, + onAttachmentItemSelected = onAttachmentItemSelected, + onRequestAccessClick = { + permissionLauncher.launch(permissions) + }, + ) + + // Access permanently denied snackbar + val snackBarHostState = remember { SnackbarHostState() } + PermissionPermanentlyDeniedSnackBar( + hostState = snackBarHostState, + onActionClick = { context.openSystemSettings() }, + ) + val snackbarMessage = stringResource(id = R.string.stream_ui_message_composer_permission_setting_message) + val snackbarAction = stringResource(id = R.string.stream_ui_message_composer_permissions_setting_button) + LaunchedEffect(showPermanentlyDeniedSnackBar) { + if (showPermanentlyDeniedSnackBar) { + snackBarHostState.showSnackbar(snackbarMessage, snackbarAction) + showPermanentlyDeniedSnackBar = false } } + } - val hasPermission = storagePermissionState.allPermissionsGranted + /** + * Renders the visual media content based on the [VisualMediaAccess] state. + * + * @param visualMediaAccess The current state of the visual media access. + * @param attachments The list of attachments to display. + * @param onAttachmentItemSelected Action invoked when the user selects an attachment. + * @param onRequestAccessClick Action invoked when the user taps on the "Give permission" button. + */ + @Composable + private fun VisualMediaAccessContent( + visualMediaAccess: VisualMediaAccess, + attachments: List, + onAttachmentItemSelected: (AttachmentPickerItemState) -> Unit, + onRequestAccessClick: () -> Unit, + ) { + when (visualMediaAccess) { + VisualMediaAccess.FULL -> { + ImagesPicker( + modifier = Modifier.padding(top = 16.dp, start = 2.dp, end = 2.dp, bottom = 2.dp), + images = attachments, + onImageSelected = onAttachmentItemSelected, + showAddMore = false, + ) + } - LaunchedEffect(storagePermissionState.allPermissionsGranted) { - if (storagePermissionState.allPermissionsGranted) { - onAttachmentsChanged( - storageHelper.getMedia().map { AttachmentPickerItemState(it, false) }, + VisualMediaAccess.PARTIAL -> { + ImagesPicker( + modifier = Modifier.padding(top = 16.dp, start = 2.dp, end = 2.dp, bottom = 2.dp), + images = attachments, + onImageSelected = onAttachmentItemSelected, + showAddMore = true, + onAddMoreClick = onRequestAccessClick, ) } - } - LaunchedEffect(Unit) { - if (!hasPermission && !storagePermissionRequested) { - storagePermissionState.launchMultiplePermissionRequest() + VisualMediaAccess.DENIED -> { + NoStorageAccessContent(onRequestAccessClick = onRequestAccessClick) } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/FilesAccess.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/FilesAccess.kt new file mode 100644 index 00000000000..d9befc1045a --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/FilesAccess.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.messages.attachments.factory + +import android.Manifest.permission.READ_EXTERNAL_STORAGE +import android.Manifest.permission.READ_MEDIA_AUDIO +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner + +/** + * Defines the possible states in which the files storage access can be. + * + * - [FilesAccess.AUDIO_AND_FULL_VISUAL] when the app has full access to visual media and audio. + * - [FilesAccess.AUDIO_AND_PARTIAL_VISUAL] when the app has partial access to visual media and full access to audio. + * - [FilesAccess.AUDIO] when the app has access to audio and no access to visual media. + * - [FilesAccess.FULL_VISUAL] when the app has full access to visual media and no access to audio. + * - [FilesAccess.PARTIAL_VISUAL] when the app has partial access to visual media and no access to audio. + * - [FilesAccess.DENIED] when the app has no access to visual media or audio. + */ +internal enum class FilesAccess { + AUDIO_AND_FULL_VISUAL, + AUDIO_AND_PARTIAL_VISUAL, + AUDIO, + FULL_VISUAL, + PARTIAL_VISUAL, + DENIED, +} + +/** + * Produces the current [FilesAccess] as [State] that can be observed in a [Composable] function. + * It updates the value on the "onResume" lifecycle event, to ensure that the latest permission state is reflected, + * to cover the case where the user changes the permission from settings and returns to the app. + * + * @param context The context to use to check the files access. + * @param lifecycleOwner The lifecycle owner to observe the files access changes. + * @param onResume Callback invoked on the "onResume" lifecycle event. It provides the latest [FilesAccess] state, and + * should be used to access the data from storage (if possible). + */ +@Composable +internal fun filesAccessAsState( + context: Context, + lifecycleOwner: LifecycleOwner, + onResume: (FilesAccess) -> Unit, +): State { + return produceState( + initialValue = FilesAccess.DENIED, + context, + lifecycleOwner, + ) { + val eventObserver = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { + val visualMediaAccess = resolveVisualMediaAccessState(context) + val audioAccess = isAudioAccessGranted(context) + value = when (visualMediaAccess) { + VisualMediaAccess.FULL -> if (audioAccess) { + FilesAccess.AUDIO_AND_FULL_VISUAL + } else { + FilesAccess.FULL_VISUAL + } + VisualMediaAccess.PARTIAL -> if (audioAccess) { + FilesAccess.AUDIO_AND_PARTIAL_VISUAL + } else { + FilesAccess.PARTIAL_VISUAL + } + VisualMediaAccess.DENIED -> if (audioAccess) { + FilesAccess.AUDIO + } else { + FilesAccess.DENIED + } + } + onResume(value) + } + } + lifecycleOwner.lifecycle.addObserver(eventObserver) + awaitDispose { + lifecycleOwner.lifecycle.removeObserver(eventObserver) + } + } +} + +private fun isAudioAccessGranted(context: Context): Boolean { + val isPermissionGranted = { permission: String -> + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + isPermissionGranted(READ_MEDIA_AUDIO) + } else { + isPermissionGranted(READ_EXTERNAL_STORAGE) + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/NoStorageAccessContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/NoStorageAccessContent.kt new file mode 100644 index 00000000000..fe89815630f --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/NoStorageAccessContent.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.messages.attachments.factory + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.theme.ChatTheme + +/** + * Shows the UI if we're missing permissions to fetch visual/audio content (images/videos/audio) for attachments. + * + * @param modifier A [Modifier] for external customisation. + * @param onRequestAccessClick Action invoked when the user taps on the "Grant permission" button. + */ +@Composable +internal fun NoStorageAccessContent( + modifier: Modifier = Modifier, + onRequestAccessClick: () -> Unit, +) { + val title = R.string.stream_ui_message_composer_permission_storage_title + val message = R.string.stream_ui_message_composer_permission_storage_message + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(16.dp), + style = ChatTheme.typography.title3Bold, + text = stringResource(id = title), + color = ChatTheme.colors.textHighEmphasis, + ) + + Spacer(modifier = Modifier.size(16.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp), + style = ChatTheme.typography.body, + text = stringResource(id = message), + textAlign = TextAlign.Center, + color = ChatTheme.colors.textLowEmphasis, + ) + + Spacer(modifier = Modifier.size(16.dp)) + + TextButton( + colors = ButtonDefaults.textButtonColors(contentColor = ChatTheme.colors.primaryAccent), + onClick = onRequestAccessClick, + ) { + Text(stringResource(id = R.string.stream_compose_grant_permission)) + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/PermissionPermanentlyDeniedSnackBar.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/PermissionPermanentlyDeniedSnackBar.kt new file mode 100644 index 00000000000..79adf8283de --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/PermissionPermanentlyDeniedSnackBar.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.messages.attachments.factory + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Snackbar +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.ui.theme.ChatTheme + +/** + * SnackBar shown when the user denies the access to the storage permanently, and requests the storage access + * again. + * + * @param hostState The state of the snackbar. + * @param onActionClick Action invoked when the user click the action on the snackbar. + */ +@Composable +internal fun PermissionPermanentlyDeniedSnackBar( + hostState: SnackbarHostState, + onActionClick: () -> Unit, +) { + SnackbarHost( + hostState = hostState, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .wrapContentHeight(Alignment.Bottom), + ) { data -> + Snackbar( + content = { + Text( + text = data.message, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + }, + action = data.actionLabel?.let { + { + TextButton( + colors = ButtonDefaults.textButtonColors(contentColor = ChatTheme.colors.primaryAccent), + onClick = onActionClick, + ) { + Text(text = it) + } + } + }, + ) + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/Permissions.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/Permissions.kt new file mode 100644 index 00000000000..d67ed3dbe2d --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/Permissions.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.messages.attachments.factory + +import android.Manifest.permission.READ_EXTERNAL_STORAGE +import android.Manifest.permission.READ_MEDIA_AUDIO +import android.Manifest.permission.READ_MEDIA_IMAGES +import android.Manifest.permission.READ_MEDIA_VIDEO +import android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED +import android.app.Activity +import android.content.Context +import android.os.Build +import androidx.core.app.ActivityCompat + +/** + * Builds an [Array] of the required permissions for accessing visual media, based on the Android version. + */ +internal fun visualMediaPermissions(): Array = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // Android 14+ + arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_VISUAL_USER_SELECTED) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Android 13 + arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO) + } else { + // Android 12 and below + arrayOf(READ_EXTERNAL_STORAGE) + } + +/** + * Builds an [Array] of the required permissions for accessing audio media, based on the Android version. + */ +internal fun audioPermissions(): Array = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Android 13+ + arrayOf(READ_MEDIA_AUDIO) + } else { + // Android 12 and below + arrayOf(READ_EXTERNAL_STORAGE) + } + +/** + * Builds an [Array] of the required permissions for accessing visual + audio media, based on the Android version. + */ +internal fun filesPermissions(): Array = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // Android 14+ + arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_VISUAL_USER_SELECTED, READ_MEDIA_AUDIO) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Android 13 + arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO) + } else { + // Android 12 and below + arrayOf(READ_EXTERNAL_STORAGE) + } + +/** + * Checks if the [grantResults] indicate that the permissions were permanently denied. + * + * @param context The calling [Context]. + * @param grantResults The results delivered by the callback of + * [androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions]. + */ +internal fun isPermanentlyDenied(context: Context, grantResults: Map): Boolean { + val activity = context as? Activity ?: return false // should never fail + return grantResults.all { (permission, granted) -> + !granted && !ActivityCompat.shouldShowRequestPermissionRationale(activity, permission) + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/VisualMediaAccess.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/VisualMediaAccess.kt new file mode 100644 index 00000000000..15657bc88ae --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/VisualMediaAccess.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.messages.attachments.factory + +import android.Manifest.permission.READ_EXTERNAL_STORAGE +import android.Manifest.permission.READ_MEDIA_IMAGES +import android.Manifest.permission.READ_MEDIA_VIDEO +import android.Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner + +/** + * Defines the possible states in which the visual media storage access can be. + * + * - [VisualMediaAccess.FULL] when the app has full access to the storage. + * - [VisualMediaAccess.PARTIAL] when the app has partial access to the storage. + * - [VisualMediaAccess.DENIED] when the app has no access to the storage. + */ +internal enum class VisualMediaAccess { + FULL, + PARTIAL, + DENIED, +} + +/** + * Produces the current [VisualMediaAccess] as [State] that can be observed in a [Composable] function. + * It updates the value on the "onResume" lifecycle event, to ensure that the latest permission state is reflected, + * to cover the case where the user changes the permission from settings and returns to the app. + * + * @param context The context to use to check the visual media access access. + * @param lifecycleOwner The lifecycle owner to observe the visual media access changes. + * @param onResume Callback invoked on the "onResume" lifecycle event. It provides the latest [VisualMediaAccess] state, + * and should be used to access the data from storage (if possible). + */ +@Composable +internal fun visualMediaAccessAsState( + context: Context, + lifecycleOwner: LifecycleOwner, + onResume: (VisualMediaAccess) -> Unit, +): State { + return produceState( + initialValue = VisualMediaAccess.DENIED, + context, + lifecycleOwner, + ) { + val eventObserver = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + value = resolveVisualMediaAccessState(context) + onResume(value) + } + } + lifecycleOwner.lifecycle.addObserver(eventObserver) + awaitDispose { + lifecycleOwner.lifecycle.removeObserver(eventObserver) + } + } +} + +/** + * Resolves the current [VisualMediaAccess] state based on the permissions granted by the user. + * + * @param context The context to use to check the permission grants. + */ +internal fun resolveVisualMediaAccessState(context: Context): VisualMediaAccess { + val isPermissionGranted = { permission: String -> + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + isPermissionGranted(READ_MEDIA_IMAGES) && + isPermissionGranted(READ_MEDIA_VIDEO) + ) { + // Full access on Android 13 (API level 33) or higher + VisualMediaAccess.FULL + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + isPermissionGranted(READ_MEDIA_VISUAL_USER_SELECTED) + ) { + // Partial access on Android 14 (API level 34) or higher + VisualMediaAccess.PARTIAL + } else if (isPermissionGranted(READ_EXTERNAL_STORAGE)) { + // Full access up to Android 12 (API level 32) + VisualMediaAccess.FULL + } else { + // Access denied + VisualMediaAccess.DENIED + } +} diff --git a/stream-chat-android-ui-common/src/main/res/values/strings.xml b/stream-chat-android-ui-common/src/main/res/values/strings.xml index d2eec5a74aa..d583fd8c7d6 100644 --- a/stream-chat-android-ui-common/src/main/res/values/strings.xml +++ b/stream-chat-android-ui-common/src/main/res/values/strings.xml @@ -32,6 +32,10 @@ Record Audio permission is needed in order to be able to record and send audio files Enable permissions on App Settings Settings + Add more + Allow access to audio files + Allow access to visual media + Allow access to more visual media Files