diff --git a/CHANGELOG.md b/CHANGELOG.md index c7442bcd71e..7bf3b6aee7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### ⚠️ Changed - Deprecate `NotInFilterObject` because it is not supported backend-side anymore. [#5393](https://github.com/GetStream/stream-chat-android/pull/5393) +- Deprecate `AttachmentsPickerTabFactories.defaultFactoriesWithoutStoragePermissions()` in favor of a more configurable method. [#5430](https://github.com/GetStream/stream-chat-android/pull/5430) +- Deprecate `AttachmentsPickerSystemTabFactory(otherFactories)` in favor of a more configurable constructor. [#5430](https://github.com/GetStream/stream-chat-android/pull/5430) ### ❌ Removed - Remove `NotInFilterObject` because it is not supported backend-side anymore. [#5394](https://github.com/GetStream/stream-chat-android/pull/5394) @@ -59,6 +61,7 @@ ## stream-chat-android-ui-components ### 🐞 Fixed - Fix max allowed votes base on available options. [#5431](https://github.com/GetStream/stream-chat-android/pull/5431) +- Fix `CAMERA` permission request when using the capture photo/video attachment picker from `AttachmentsPickerSystemTabFactory`. [#5430](https://github.com/GetStream/stream-chat-android/pull/5430) ### ⬆️ Improved @@ -70,6 +73,7 @@ ## stream-chat-android-compose ### 🐞 Fixed +- Fix `CAMERA` permission request when using the capture photo/video attachment picker from `AttachmentsPickerSystemTabFactory`. [#5430](https://github.com/GetStream/stream-chat-android/pull/5430) ### ⬆️ Improved @@ -77,6 +81,8 @@ - Added `ChannelListViewModel.refresh` method to refresh the channel list. [#5425](https://github.com/GetStream/stream-chat-android/pull/5425) - Add `PinnedMessageList` component for showing the list of pinned messages in a channel. [#5420](https://github.com/GetStream/stream-chat-android/pull/5420) - Add `formatMessageTitle` method to `MessagePreviewFormatter`, to allow message preview title formatting customization. [#5420](https://github.com/GetStream/stream-chat-android/pull/5420) +- Add `AttachmentsPickerTabFactories.defaultFactoriesWithoutStoragePermissions` to customize which attachment pickers are allowed. [#5430](https://github.com/GetStream/stream-chat-android/pull/5430) +- Add `AttachmentsPickerSystemTabFactory(filesAllowed, mediaAllowed, captureImageAllowed, captureVideoAllowed, pollAllowed)` to to customize which attachment pickers are allowed. [#5430](https://github.com/GetStream/stream-chat-android/pull/5430) ### ⚠️ Changed - Exposed `DefaultMessageComposerRecordingContent` and `DefaultAudioRecordButton` components. [#5433](https://github.com/GetStream/stream-chat-android/pull/5433) 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 00e90f0f904..aeb09d62e4c 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1556,6 +1556,7 @@ public final class io/getstream/chat/android/compose/ui/messages/attachments/fac public final class io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerSystemTabFactory : io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerTabFactory { public static final field $stable I public fun (Ljava/util/List;)V + public fun (ZZZZZ)V public fun PickerTabContent (Lkotlin/jvm/functions/Function1;Ljava/util/List;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public fun PickerTabIcon (ZZLandroidx/compose/runtime/Composer;I)V public fun getAttachmentsPickerMode ()Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentsPickerMode; @@ -1568,6 +1569,8 @@ public final class io/getstream/chat/android/compose/ui/messages/attachments/fac public final fun defaultFactories (ZZZZZ)Ljava/util/List; public static synthetic fun defaultFactories$default (Lio/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerTabFactories;ZZZZZILjava/lang/Object;)Ljava/util/List; public final fun defaultFactoriesWithoutStoragePermissions ()Ljava/util/List; + public final fun defaultFactoriesWithoutStoragePermissions (ZZZZZ)Ljava/util/List; + public static synthetic fun defaultFactoriesWithoutStoragePermissions$default (Lio/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerTabFactories;ZZZZZILjava/lang/Object;)Ljava/util/List; } public abstract interface class io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerTabFactory { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerMediaCaptureTabFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerMediaCaptureTabFactory.kt index 374be3c7690..8afc3ae0c1a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerMediaCaptureTabFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerMediaCaptureTabFactory.kt @@ -17,8 +17,6 @@ package io.getstream.chat.android.compose.ui.messages.attachments.factory import android.Manifest -import android.content.Context -import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -39,6 +37,7 @@ import io.getstream.chat.android.compose.state.messages.attachments.MediaCapture import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.ui.common.contract.internal.CaptureMediaContract import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData +import io.getstream.chat.android.ui.common.utils.isPermissionDeclared import java.io.File /** @@ -94,7 +93,7 @@ public class AttachmentsPickerMediaCaptureTabFactory(private val pickerMediaMode ) { val context = LocalContext.current - val requiresCameraPermission = isCameraPermissionDeclared(context) + val requiresCameraPermission = context.isPermissionDeclared(Manifest.permission.CAMERA) val cameraPermissionState = if (requiresCameraPermission) rememberPermissionState(permission = Manifest.permission.CAMERA) else null @@ -121,19 +120,6 @@ public class AttachmentsPickerMediaCaptureTabFactory(private val pickerMediaMode } } - /** - * Returns if we need to check for the camera permission or not. - * - * @param context The context of the app. - * @return If the camera permission is declared in the manifest or not. - */ - private fun isCameraPermissionDeclared(context: Context): Boolean { - return context.packageManager - .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) - .requestedPermissions - .contains(Manifest.permission.CAMERA) - } - /** * Define which media type will be allowed. */ diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerSystemTabFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerSystemTabFactory.kt index 667d4cb7870..7cd642c622f 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerSystemTabFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerSystemTabFactory.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.compose.ui.messages.attachments.factory +import android.Manifest import android.app.Activity import android.content.Intent import android.net.Uri @@ -27,7 +28,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -35,11 +36,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card import androidx.compose.material.Icon 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 @@ -56,6 +61,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale 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 @@ -64,15 +73,56 @@ import io.getstream.chat.android.compose.state.messages.attachments.MediaCapture import io.getstream.chat.android.compose.state.messages.attachments.Poll import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.StorageHelperWrapper +import io.getstream.chat.android.ui.common.contract.internal.CaptureMediaContract 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.ui.common.utils.isPermissionDeclared +import java.io.File /** * Holds the information required to add support for "files" tab in the attachment picker. + * + * @param filesAllowed If the option to pick files is included in the attachments picker. + * @param mediaAllowed If the option to pick media (images/videos) is included in the attachments picker. + * @param captureImageAllowed If the option to capture an image is included in the attachments picker. + * @param captureVideoAllowed If the option to capture a video is included in the attachments picker. + * @param pollAllowed If the option to create a poll is included in the attachments picker. */ -public class AttachmentsPickerSystemTabFactory(private val otherFactories: List) : - AttachmentsPickerTabFactory { +public class AttachmentsPickerSystemTabFactory( + private val filesAllowed: Boolean, + private val mediaAllowed: Boolean, + private val captureImageAllowed: Boolean, + private val captureVideoAllowed: Boolean, + private val pollAllowed: Boolean, +) : AttachmentsPickerTabFactory { + + /** + * Holds the information required to add support for "files" tab in the attachment picker. + * + * @param otherFactories A list of other [AttachmentsPickerTabFactory] used to handle different attachment pickers. + */ + @Deprecated( + message = "Use constructor(filesAllowed, mediaAllowed, captureImageAllowed, captureVideoAllowed, pollAllowed)" + + " instead.", + replaceWith = ReplaceWith( + expression = "AttachmentsPickerSystemTabFactory(filesAllowed, mediaAllowed, captureImageAllowed," + + " captureVideoAllowed, pollAllowed)", + ), + level = DeprecationLevel.WARNING, + ) + public constructor(otherFactories: List) : this( + filesAllowed = true, + mediaAllowed = true, + captureImageAllowed = otherFactories.any { it.attachmentsPickerMode == MediaCapture }, + captureVideoAllowed = otherFactories.any { it.attachmentsPickerMode == MediaCapture }, + pollAllowed = otherFactories.any { it.attachmentsPickerMode == Poll }, + ) + + private val mediaPickerContract = resolveMediaPickerMode(captureImageAllowed, captureVideoAllowed) + ?.let(::CaptureMediaContract) + + private val pollFactory by lazy { AttachmentsPickerPollTabFactory() } /** * The attachment picker mode that this factory handles. @@ -109,6 +159,7 @@ public class AttachmentsPickerSystemTabFactory(private val otherFactories: List< * @param onAttachmentsSubmitted Handler to submit the selected attachments to the message composer. */ @OptIn(ExperimentalPermissionsApi::class) + @Suppress("LongMethod") @Composable override fun PickerTabContent( onAttachmentPickerAction: (AttachmentPickerAction) -> Unit, @@ -118,200 +169,243 @@ public class AttachmentsPickerSystemTabFactory(private val otherFactories: List< onAttachmentsSubmitted: (List) -> Unit, ) { val context = LocalContext.current - val attachmentFilter = AttachmentFilter() val storageHelper: StorageHelperWrapper = remember { - StorageHelperWrapper(context, StorageHelper(), attachmentFilter) + StorageHelperWrapper(context, StorageHelper(), AttachmentFilter()) } - val filePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) { result -> - // Handle the file URI - if (result.resultCode == Activity.RESULT_OK) { - val uri = result.data?.data - uri?.let { - val attachmentMetadata = storageHelper.getAttachmentsMetadataFromUris(listOf(uri)) - onAttachmentsSubmitted(attachmentMetadata) - } - } + val filePickerLauncher = rememberFilePickerLauncher { uri -> + onAttachmentsSubmitted(storageHelper.getAttachmentsMetadataFromUris(listOf(uri))) } - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickVisualMedia(), - ) { uri: Uri? -> - // Handle the image URI - uri?.let { - val attachmentMetadata = storageHelper.getAttachmentsMetadataFromUris(listOf(uri)) - onAttachmentsSubmitted(attachmentMetadata) + val imagePickerLauncher = rememberImagePickerLauncher { uri -> + onAttachmentsSubmitted(storageHelper.getAttachmentsMetadataFromUris(listOf(uri))) + } + + val captureLauncher = rememberCaptureMediaLauncher { file -> + onAttachmentsSubmitted(listOf(AttachmentMetaData(context, file))) + } + var cameraRationaleShown by remember { mutableStateOf(false) } + val cameraPermissionRequired = context.isPermissionDeclared(Manifest.permission.CAMERA) + val cameraPermissionState = if (cameraPermissionRequired) { + rememberPermissionState(Manifest.permission.CAMERA) { + if (it) captureLauncher?.launch(Unit) } + } else { + null } - InnerContent( - InnerContentParams( - attachments = attachments, - otherFactories = otherFactories, - ), - InnerContentActions( - onAttachmentItemSelected = onAttachmentItemSelected, - onAttachmentsChanged = onAttachmentsChanged, - onAttachmentsSubmitted = onAttachmentsSubmitted, - onAttachmentPickerAction = onAttachmentPickerAction, - onFilesClick = { - // Start file picker - val filePickerIntent = Intent(Intent.ACTION_GET_CONTENT).apply { - type = "*/*" // General type to include multiple types - putExtra(Intent.EXTRA_MIME_TYPES, attachmentFilter.getSupportedMimeTypes().toTypedArray()) - addCategory(Intent.CATEGORY_OPENABLE) - } - - filePickerLauncher.launch(filePickerIntent) - }, - onImagesClick = { - // Start photo picker - imagePickerLauncher.launch( - PickVisualMediaRequest( - ActivityResultContracts.PickVisualMedia.ImageAndVideo, - ), - ) - }, - ), + var pollShown by remember { mutableStateOf(false) } + + val buttonsConfig = ButtonsConfig( + filesAllowed = filesAllowed, + mediaAllowed = mediaAllowed, + captureAllowed = mediaPickerContract != null, + pollAllowed = pollAllowed, + ) + val buttonActions = ButtonActions( + onFilesClick = { filePickerLauncher.launch(filePickerIntent()) }, + onMediaClick = { + imagePickerLauncher + .launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) + }, + onCaptureClick = { + // Permission grant is needed only if CAMERA is declared in the Manifest and is not yet granted + if (!cameraPermissionRequired || cameraPermissionState?.status?.isGranted == true) { + captureLauncher?.launch(Unit) + } else if (cameraPermissionState?.status?.shouldShowRationale == true) { + cameraRationaleShown = true + } else { + cameraPermissionState?.launchPermissionRequest() + } + }, + onPollClick = { pollShown = true }, ) - } -} -@Composable -private fun InnerContent(params: InnerContentParams, actions: InnerContentActions) { - val pollsFactory = remember { - params.otherFactories.firstOrNull { it.attachmentsPickerMode == Poll } - } - val mediaCaptureTabFactory = remember { - params.otherFactories.firstOrNull { it.attachmentsPickerMode == MediaCapture } - } + ButtonRow(config = buttonsConfig, actions = buttonActions) - var pollSelected by remember { - mutableStateOf(false) - } - var mediaSelected by remember { - mutableStateOf(false) + if (pollShown) { + PollDialog( + factory = pollFactory, + attachments = attachments, + actions = PollDialogActions( + onAttachmentPickerAction = onAttachmentPickerAction, + onAttachmentsChanged = onAttachmentsChanged, + onAttachmentItemSelected = onAttachmentItemSelected, + onAttachmentsSubmitted = onAttachmentsSubmitted, + onDismissPollDialog = { pollShown = false }, + ), + ) + } + + if (cameraRationaleShown && cameraPermissionState != null) { + CameraPermissionDialog( + permissionState = cameraPermissionState, + onDismiss = { cameraRationaleShown = false }, + ) + } + + LaunchedEffect(cameraPermissionState?.status) { + cameraRationaleShown = false + } } - DialogContent( - DialogContentParams( - pollsFactory = pollsFactory, - mediaCaptureTabFactory = mediaCaptureTabFactory, - mediaSelected = mediaSelected, - pollSelected = pollSelected, - attachments = params.attachments, - ), - DialogContentActions( - onAttachmentPickerAction = actions.onAttachmentPickerAction, - onAttachmentsChanged = actions.onAttachmentsChanged, - onAttachmentItemSelected = actions.onAttachmentItemSelected, - onAttachmentsSubmitted = actions.onAttachmentsSubmitted, - onDismissPollDialog = { pollSelected = false }, - ), - ) + @Composable + private fun rememberFilePickerLauncher(onResult: (Uri) -> Unit) = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val uri = result.data?.data + uri?.let(onResult) + } + } - ButtonRow( - onFilesClick = actions.onFilesClick, - onImagesClick = actions.onImagesClick, - onMediaClick = { mediaSelected = !mediaSelected }, - onPollClick = { pollSelected = !pollSelected }, - ) + @Composable + private fun rememberImagePickerLauncher(onResult: (Uri) -> Unit) = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + uri?.let(onResult) + } + + @Composable + private fun rememberCaptureMediaLauncher(onResult: (File) -> Unit) = + mediaPickerContract?.let { + rememberLauncherForActivityResult(mediaPickerContract) { file -> + file?.let(onResult) + } + } + + private fun resolveMediaPickerMode(captureImageAllowed: Boolean, captureVideoAllowed: Boolean) = when { + captureImageAllowed && captureVideoAllowed -> CaptureMediaContract.Mode.PHOTO_AND_VIDEO + captureImageAllowed -> CaptureMediaContract.Mode.PHOTO + captureVideoAllowed -> CaptureMediaContract.Mode.VIDEO + else -> null + } + + private fun filePickerIntent(): Intent { + val attachmentFilter = AttachmentFilter() + return Intent(Intent.ACTION_GET_CONTENT).apply { + type = "*/*" // General type to include multiple types + putExtra(Intent.EXTRA_MIME_TYPES, attachmentFilter.getSupportedMimeTypes().toTypedArray()) + addCategory(Intent.CATEGORY_OPENABLE) + } + } } @Composable -private fun DialogContent(params: DialogContentParams, actions: DialogContentActions) { - if (params.mediaSelected) { - params.mediaCaptureTabFactory?.PickerTabContent( - onAttachmentPickerAction = actions.onAttachmentPickerAction, - attachments = params.attachments, - onAttachmentsChanged = actions.onAttachmentsChanged, - onAttachmentItemSelected = actions.onAttachmentItemSelected, - onAttachmentsSubmitted = actions.onAttachmentsSubmitted, - ) +private fun ButtonRow( + config: ButtonsConfig, + actions: ButtonActions, +) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + if (config.filesAllowed) { + item { + FilesButton(actions.onFilesClick) + } + } + if (config.mediaAllowed) { + item { + MediaButton(actions.onMediaClick) + } + } + if (config.captureAllowed) { + item { + CaptureButton(actions.onCaptureClick) + } + } + if (config.pollAllowed) { + item { + PollButton(actions.onPollClick) + } + } } +} - if (params.pollSelected) { - Dialog( - properties = DialogProperties( - usePlatformDefaultWidth = false, - ), - onDismissRequest = actions.onDismissPollDialog, +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun CameraPermissionDialog( + permissionState: PermissionState, + onDismiss: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Box( + modifier = Modifier + .wrapContentSize() + .background(ChatTheme.colors.barsBackground, RoundedCornerShape(16.dp)), + contentAlignment = Alignment.BottomCenter, ) { - Box( - modifier = Modifier - .background(ChatTheme.colors.appBackground) - .fillMaxWidth() - .fillMaxHeight(), // Ensure the dialog fills the height - ) { - params.pollsFactory?.PickerTabContent( - onAttachmentPickerAction = actions.onAttachmentPickerAction, - attachments = params.attachments, - onAttachmentsChanged = actions.onAttachmentsChanged, - onAttachmentItemSelected = actions.onAttachmentItemSelected, - onAttachmentsSubmitted = actions.onAttachmentsSubmitted, - ) - } + MissingPermissionContent(permissionState) } } } @Composable -private fun ButtonRow( - onFilesClick: () -> Unit, - onImagesClick: () -> Unit, - onMediaClick: () -> Unit, - onPollClick: () -> Unit, +private fun PollDialog( + factory: AttachmentsPickerPollTabFactory, + attachments: List, + actions: PollDialogActions, ) { - val buttons = listOf<@Composable () -> Unit>( - { - RoundedIconButton( - onClick = onFilesClick, - iconPainter = painterResource(id = R.drawable.stream_compose_ic_file_picker), - contentDescription = stringResource(id = R.string.stream_compose_files_option), - text = stringResource(id = R.string.stream_compose_files_option), - ) - }, - { - RoundedIconButton( - onClick = onImagesClick, - iconPainter = painterResource(id = R.drawable.stream_compose_ic_image_picker), - contentDescription = stringResource(id = R.string.stream_compose_images_option), - text = stringResource(id = R.string.stream_compose_images_option), - ) - }, - { - RoundedIconButton( - onClick = onMediaClick, - iconPainter = painterResource(id = R.drawable.stream_compose_ic_media_picker), - contentDescription = stringResource(id = R.string.stream_ui_message_composer_capture_media_take_photo), - text = stringResource(id = R.string.stream_ui_message_composer_capture_media_take_photo), - ) - }, - { - RoundedIconButton( - onClick = onPollClick, - iconPainter = painterResource(id = R.drawable.stream_compose_ic_poll), - contentDescription = stringResource(id = R.string.stream_compose_poll_option), - text = stringResource(id = R.string.stream_compose_poll_option), - ) - }, - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + Dialog( + properties = DialogProperties( + usePlatformDefaultWidth = false, + ), + onDismissRequest = actions.onDismissPollDialog, ) { - buttons.forEach { button -> - button() + Box( + modifier = Modifier + .background(ChatTheme.colors.appBackground) + .fillMaxWidth() + .fillMaxHeight(), // Ensure the dialog fills the height + ) { + factory.PickerTabContent( + onAttachmentPickerAction = actions.onAttachmentPickerAction, + attachments = attachments, + onAttachmentsChanged = actions.onAttachmentsChanged, + onAttachmentItemSelected = actions.onAttachmentItemSelected, + onAttachmentsSubmitted = actions.onAttachmentsSubmitted, + ) } } } +@Composable +private fun FilesButton(onClick: () -> Unit) = + RoundedIconButton( + onClick = onClick, + iconPainter = painterResource(id = R.drawable.stream_compose_ic_file_picker), + contentDescription = stringResource(id = R.string.stream_compose_files_option), + text = stringResource(id = R.string.stream_compose_files_option), + ) + +@Composable +private fun MediaButton(onClick: () -> Unit) = + RoundedIconButton( + onClick = onClick, + iconPainter = painterResource(id = R.drawable.stream_compose_ic_image_picker), + contentDescription = stringResource(id = R.string.stream_compose_images_option), + text = stringResource(id = R.string.stream_compose_images_option), + ) + +@Composable +private fun CaptureButton(onClick: () -> Unit) = + RoundedIconButton( + onClick = onClick, + iconPainter = painterResource(id = R.drawable.stream_compose_ic_media_picker), + contentDescription = stringResource(id = R.string.stream_ui_message_composer_capture_media_take_photo), + text = stringResource(id = R.string.stream_ui_message_composer_capture_media_take_photo), + ) + +@Composable +private fun PollButton(onClick: () -> Unit) = + RoundedIconButton( + onClick = onClick, + iconPainter = painterResource(id = R.drawable.stream_compose_ic_poll), + contentDescription = stringResource(id = R.string.stream_compose_poll_option), + text = stringResource(id = R.string.stream_compose_poll_option), + ) + @Composable private fun RoundedIconButton( onClick: () -> Unit, @@ -363,32 +457,24 @@ private fun RoundedIconButton( // Data classes to combine parameters. -private data class InnerContentParams( - val otherFactories: List, - val attachments: List, +private data class ButtonsConfig( + val filesAllowed: Boolean, + val mediaAllowed: Boolean, + val captureAllowed: Boolean, + val pollAllowed: Boolean, ) -private data class InnerContentActions( - val onAttachmentPickerAction: (AttachmentPickerAction) -> Unit, - val onAttachmentsChanged: (List) -> Unit, - val onAttachmentItemSelected: (AttachmentPickerItemState) -> Unit, - val onAttachmentsSubmitted: (List) -> Unit, +private data class ButtonActions( val onFilesClick: () -> Unit, - val onImagesClick: () -> Unit, + val onMediaClick: () -> Unit, + val onCaptureClick: () -> Unit, + val onPollClick: () -> Unit, ) -private data class DialogContentActions( +private data class PollDialogActions( val onAttachmentPickerAction: (AttachmentPickerAction) -> Unit, val onAttachmentsChanged: (List) -> Unit, val onAttachmentItemSelected: (AttachmentPickerItemState) -> Unit, val onAttachmentsSubmitted: (List) -> Unit, val onDismissPollDialog: () -> Unit, ) - -private data class DialogContentParams( - val pollsFactory: AttachmentsPickerTabFactory?, - val mediaCaptureTabFactory: AttachmentsPickerTabFactory?, - val mediaSelected: Boolean, - val pollSelected: Boolean, - val attachments: List, -) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerTabFactories.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerTabFactories.kt index 145c5caacb8..ad56a4117d5 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerTabFactories.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerTabFactories.kt @@ -22,6 +22,15 @@ package io.getstream.chat.android.compose.ui.messages.attachments.factory */ public object AttachmentsPickerTabFactories { + @Deprecated( + message = "Use defaultFactoriesWithoutStoragePermissions(filesAllowed: Boolean, mediaAllowed: Boolean = true," + + " captureImageAllowed: Boolean, captureVideoAllowed: Boolean, pollAllowed: Boolean = true) instead.", + replaceWith = ReplaceWith( + expression = "defaultFactoriesWithoutStoragePermissions(filesAllowed, mediaAllowed, captureImageAllowed," + + " captureVideoAllowed, pollAllowed)", + ), + level = DeprecationLevel.WARNING, + ) public fun defaultFactoriesWithoutStoragePermissions(): List { val otherFactories = defaultFactories( imagesTabEnabled = false, @@ -33,6 +42,32 @@ public object AttachmentsPickerTabFactories { return listOf(AttachmentsPickerSystemTabFactory(otherFactories)) } + /** + * Builds the default list of attachment picker tab factories (without requesting storage permission). + * + * @param filesAllowed If the option to pick files is included in the attachments picker. + * @param mediaAllowed If the option to pick media (images/videos) is included in the attachments picker. + * @param captureImageAllowed If the option to capture an image is included in the attachments picker. + * @param captureVideoAllowed If the option to capture a video is included in the attachments picker. + * @param pollAllowed If the option to create a poll is included in the attachments picker. + */ + public fun defaultFactoriesWithoutStoragePermissions( + filesAllowed: Boolean = true, + mediaAllowed: Boolean = true, + captureImageAllowed: Boolean = true, + captureVideoAllowed: Boolean = true, + pollAllowed: Boolean = true, + ): List { + val factory = AttachmentsPickerSystemTabFactory( + filesAllowed = filesAllowed, + mediaAllowed = mediaAllowed, + captureImageAllowed = captureImageAllowed, + captureVideoAllowed = captureVideoAllowed, + pollAllowed = pollAllowed, + ) + return listOf(factory) + } + /** * Builds the default list of attachment picker tab factories. * diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/MissingPermissionContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/MissingPermissionContent.kt index 094abfeb591..88429a123d6 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/MissingPermissionContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/MissingPermissionContent.kt @@ -19,7 +19,6 @@ package io.getstream.chat.android.compose.ui.messages.attachments.factory import android.Manifest import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.ButtonDefaults @@ -43,10 +42,14 @@ import io.getstream.chat.android.uiutils.util.openSystemSettings * The UI explains to the user which permission is missing and why we need it. * * @param permissionState A state object to control and observe permission status changes. + * @param modifier A [Modifier] for external customisation. */ @OptIn(ExperimentalPermissionsApi::class) @Composable -internal fun MissingPermissionContent(permissionState: PermissionState) { +internal fun MissingPermissionContent( + permissionState: PermissionState, + modifier: Modifier = Modifier, +) { val title = when (permissionState.permission) { Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.READ_MEDIA_IMAGES, @@ -68,7 +71,7 @@ internal fun MissingPermissionContent(permissionState: PermissionState) { val context = LocalContext.current Column( - modifier = Modifier.fillMaxSize(), + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/factory/system/internal/AttachmentsPickerSystemFragment.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/factory/system/internal/AttachmentsPickerSystemFragment.kt index e397a202554..f0ceda2d80d 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/factory/system/internal/AttachmentsPickerSystemFragment.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/factory/system/internal/AttachmentsPickerSystemFragment.kt @@ -37,6 +37,7 @@ import io.getstream.chat.android.ui.feature.messages.composer.attachment.picker. import io.getstream.chat.android.ui.feature.messages.composer.attachment.picker.factory.camera.internal.CameraAttachmentFragment.Companion.mode import io.getstream.chat.android.ui.feature.messages.composer.attachment.picker.factory.camera.internal.CameraAttachmentFragment.LauncherRequestsKeys import io.getstream.chat.android.ui.feature.messages.composer.attachment.picker.poll.CreatePollDialogFragment +import io.getstream.chat.android.ui.utils.PermissionChecker import io.getstream.chat.android.ui.utils.extensions.getFragmentManager import io.getstream.chat.android.ui.utils.extensions.streamThemeInflater import java.io.File @@ -49,6 +50,8 @@ internal class AttachmentsPickerSystemFragment : Fragment() { private lateinit var style: AttachmentsPickerDialogStyle + private val permissionChecker: PermissionChecker = PermissionChecker() + /** * A listener invoked when attachments are selected in the attachment tab. */ @@ -114,7 +117,6 @@ internal class AttachmentsPickerSystemFragment : Fragment() { // Setup listeners and actions binding.buttonFiles.setOnClickListener { - val supportedMimeTypes = attachmentFilter.getSupportedMimeTypes() val filePickerIntent = Intent(Intent.ACTION_GET_CONTENT).apply { type = "*/*" // General type to include multiple types putExtra(Intent.EXTRA_MIME_TYPES, attachmentFilter.getSupportedMimeTypes().toTypedArray()) @@ -130,23 +132,24 @@ internal class AttachmentsPickerSystemFragment : Fragment() { ), ) } - captureMedia = activity?.activityResultRegistry - ?.register( - LauncherRequestsKeys.CAPTURE_MEDIA, - CaptureMediaContract(style.pickerMediaMode.mode), - ) { file: File? -> - val result: List = if (file == null) { - emptyList() - } else { - listOf(AttachmentMetaData(requireContext(), file)) - } - - attachmentsPickerTabListener?.onSelectedAttachmentsChanged(result) - attachmentsPickerTabListener?.onSelectedAttachmentsSubmitted() + captureMedia = activity?.activityResultRegistry?.register( + LauncherRequestsKeys.CAPTURE_MEDIA, + CaptureMediaContract(style.pickerMediaMode.mode), + ) { file: File? -> + val result: List = if (file == null) { + emptyList() + } else { + listOf(AttachmentMetaData(requireContext(), file)) } + + attachmentsPickerTabListener?.onSelectedAttachmentsChanged(result) + attachmentsPickerTabListener?.onSelectedAttachmentsSubmitted() + } captureMedia?.let { binding.buttonCapture.setOnClickListener { - captureMedia?.launch(Unit) + checkCameraPermissions { + captureMedia?.launch(Unit) + } } } @@ -184,6 +187,18 @@ internal class AttachmentsPickerSystemFragment : Fragment() { this.style = style } + private fun checkCameraPermissions(onPermissionGranted: () -> Unit) { + if (permissionChecker.isNeededToRequestForCameraPermissions(requireContext())) { + permissionChecker.checkCameraPermissions( + view = binding.root, + onPermissionDenied = { /* Do nothing */ }, + onPermissionGranted = onPermissionGranted, + ) + } else { + onPermissionGranted() + } + } + companion object { fun newInstance( diff --git a/stream-chat-android-ui-components/src/main/res/layout/stream_ui_fragment_attachment_system_picker.xml b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_fragment_attachment_system_picker.xml index 7faae5b23e5..d8a819e71ee 100644 --- a/stream-chat-android-ui-components/src/main/res/layout/stream_ui_fragment_attachment_system_picker.xml +++ b/stream-chat-android-ui-components/src/main/res/layout/stream_ui_fragment_attachment_system_picker.xml @@ -21,169 +21,178 @@ android:layout_height="wrap_content" > - + app:layout_constraintTop_toTopOf="parent" + > - - + - + + + + + - + - + - - - - - - - + + + + + - + - + - - - - - - - + + + + + - + - + - - - - - - - + + + + + - - - + - + \ No newline at end of file