From e384832a9ac498c1a029e4ff6046d0dc0dd36b3d Mon Sep 17 00:00:00 2001 From: Evgenii Kozlov Date: Fri, 6 Jun 2025 16:13:13 +0200 Subject: [PATCH] DROID-3047 Chats | Enhancement | Allow capturing pictures by camera and sending it as attachments (#2510) --- feature-chats/build.gradle | 1 + .../feature_chats/presentation/ChatView.kt | 3 +- .../presentation/ChatViewModel.kt | 26 +++-- .../anytype/feature_chats/tools/Media.kt | 29 +++++ .../anytype/feature_chats/ui/ChatBox.kt | 103 +++++++++++++++--- .../anytype/feature_chats/ui/ChatPreviews.kt | 3 +- .../anytype/feature_chats/ui/ChatScreen.kt | 21 +++- localization/src/main/res/values/strings.xml | 2 + 8 files changed, 159 insertions(+), 29 deletions(-) create mode 100644 feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/tools/Media.kt diff --git a/feature-chats/build.gradle b/feature-chats/build.gradle index 9b00cb1f9b..f9ac483121 100644 --- a/feature-chats/build.gradle +++ b/feature-chats/build.gradle @@ -34,6 +34,7 @@ dependencies { implementation libs.appcompat implementation libs.compose + implementation libs.activityCompose implementation libs.composeFoundation implementation libs.composeToolingPreview implementation libs.composeMaterial3 diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt index b0544d205d..4ae0ed706f 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt @@ -119,7 +119,8 @@ sealed interface ChatView { data class Media( val uri: String, val state: State = State.Idle, - val isVideo: Boolean = false + val isVideo: Boolean = false, + val capturedByCamera: Boolean = false ): ChatBoxAttachment() data class File( diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt index 8806396edb..5f7bc37d26 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt @@ -76,7 +76,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -import com.anytypeio.anytype.presentation.multiplayer.ShareSpaceViewModel.ShareLinkViewState class ChatViewModel @Inject constructor( private val vmParams: Params.Default, @@ -129,11 +128,12 @@ class ChatViewModel @Inject constructor( private val dateFormatter = SimpleDateFormat("d MMMM YYYY") private val messageRateLimiter = MessageRateLimiter() + private var capturedImageUri: String? = null + private var account: Id = "" init { - -// generateDummyChatHistory() + Timber.d("DROID-2966 init") viewModelScope.launch { spacePermissionProvider @@ -593,10 +593,17 @@ class ChatViewModel @Inject constructor( ) ) } + val path = if (attachment.capturedByCamera) { + withContext(dispatchers.io) { + copyFileToCacheDirectory.copy(attachment.uri) + }.orEmpty() + } else { + attachment.uri + } uploadFile.async( UploadFile.Params( space = vmParams.space, - path = attachment.uri, + path = path, type = if (attachment.isVideo) Block.Content.File.Type.VIDEO else @@ -621,6 +628,7 @@ class ChatViewModel @Inject constructor( ) } }.onFailure { + Timber.e(it, "DROID-2966 Error while uploading file as attachment") chatBoxAttachments.value = currAttachments.toMutableList().apply { set( index = idx, @@ -1006,17 +1014,18 @@ class ChatViewModel @Inject constructor( } fun onChatBoxMediaPicked(uris: List) { - Timber.d("onChatBoxMediaPicked: $uris") + Timber.d("DROID-2966 onChatBoxMediaPicked: $uris") chatBoxAttachments.value += uris.map { uri -> ChatView.Message.ChatBoxAttachment.Media( uri = uri.uri, - isVideo = uri.isVideo + isVideo = uri.isVideo, + capturedByCamera = uri.capturedByCamera ) } } fun onChatBoxFilePicked(infos: List) { - Timber.d("onChatBoxFilePicked: $infos") + Timber.d("DROID-2966 onChatBoxFilePicked: $infos") chatBoxAttachments.value += infos.map { info -> ChatView.Message.ChatBoxAttachment.File( uri = info.uri, @@ -1451,7 +1460,8 @@ class ChatViewModel @Inject constructor( data class ChatBoxMediaUri( val uri: String, - val isVideo: Boolean = false + val isVideo: Boolean = false, + val capturedByCamera: Boolean = false ) sealed class ViewModelCommand { diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/tools/Media.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/tools/Media.kt new file mode 100644 index 0000000000..922e1a78ce --- /dev/null +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/tools/Media.kt @@ -0,0 +1,29 @@ +package com.anytypeio.anytype.feature_chats.tools + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.core.content.FileProvider +import java.io.File + + +fun launchCamera( + context: Context, + launcher: ManagedActivityResultLauncher, + onUriReceived: (Uri) -> Unit +) { + val photoFile = File.createTempFile("IMG_", ".jpg", context.cacheDir).apply { + createNewFile() + deleteOnExit() + } + + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + photoFile + ) + + onUriReceived(uri) + + launcher.launch(uri) +} \ No newline at end of file diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt index 2a0ce9aa0d..d22da6b7e8 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt @@ -1,5 +1,6 @@ package com.anytypeio.anytype.feature_chats.ui +import android.Manifest import android.net.Uri import android.util.Patterns import androidx.activity.compose.rememberLauncherForActivityResult @@ -39,6 +40,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -48,7 +50,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -72,9 +74,11 @@ import com.anytypeio.anytype.core_ui.foundation.noRippleClickable import com.anytypeio.anytype.core_ui.views.Caption1Medium import com.anytypeio.anytype.core_ui.views.Caption1Regular import com.anytypeio.anytype.core_ui.views.ContentMiscChat +import com.anytypeio.anytype.core_utils.ext.toast import com.anytypeio.anytype.feature_chats.R import com.anytypeio.anytype.feature_chats.presentation.ChatView import com.anytypeio.anytype.feature_chats.presentation.ChatViewModel.ChatBoxMode +import com.anytypeio.anytype.feature_chats.tools.launchCamera import com.anytypeio.anytype.presentation.confgs.ChatConfig import kotlinx.coroutines.launch import timber.log.Timber @@ -99,9 +103,12 @@ fun ChatBox( onExitEditMessageMode: () -> Unit, onValueChange: (TextFieldValue, List) -> Unit, onUrlInserted: (Url) -> Unit, + onImageCaptured: (Uri) -> Unit ) { - val length = text.text.length + val context = LocalContext.current + + // LAUNCHERS val uploadMediaLauncher = rememberLauncherForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(maxItems = ChatConfig.MAX_ATTACHMENT_COUNT) @@ -115,12 +122,41 @@ fun ChatBox( onChatBoxFilePicked(uris.take(ChatConfig.MAX_ATTACHMENT_COUNT)) } + var capturedImageUri by rememberSaveable { mutableStateOf(null) } + + val cameraLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { isSuccess -> + if (isSuccess && capturedImageUri != null) { + onImageCaptured(Uri.parse(capturedImageUri)) + capturedImageUri = null + } else { + Timber.w("DROID-2966 Failed to capture image") + } + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + launchCamera( + context = context, + launcher = cameraLauncher, + onUriReceived = { capturedImageUri = it.toString() } + ) + } else { + context.toast(context.getString(R.string.chat_camera_permission_denied)) + } + } + + // END OF LAUNCHERS + + val length = text.text.length + var showDropdownMenu by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - val focus = LocalFocusManager.current - var isFocused by remember { mutableStateOf(false) } var showMarkup by remember { mutableStateOf(false) } @@ -261,6 +297,22 @@ fun ChatBox( paddingStart = 0.dp, paddingEnd = 0.dp ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.chat_box_camera), + color = colorResource(id = R.color.text_primary) + ) + }, + onClick = { + showDropdownMenu = false + permissionLauncher.launch(Manifest.permission.CAMERA) + } + ) + Divider( + paddingStart = 0.dp, + paddingEnd = 0.dp + ) DropdownMenuItem( text = { Text( @@ -337,12 +389,14 @@ fun ChatBox( ) { Text( text = "${text.text.length} / ${ChatConfig.MAX_MESSAGE_CHARACTER_LIMIT}", - modifier = Modifier.padding( - horizontal = 8.dp, - vertical = 3.dp - ).align( - Alignment.Center - ), + modifier = Modifier + .padding( + horizontal = 8.dp, + vertical = 3.dp + ) + .align( + Alignment.Center + ), style = Caption1Regular, color = if (length > ChatConfig.MAX_MESSAGE_CHARACTER_LIMIT) colorResource(R.color.palette_system_red) @@ -487,6 +541,9 @@ fun ChatBox( uploadMediaLauncher.launch( PickVisualMediaRequest(mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly) ) + }, + onCameraCaptureClicked = { + permissionLauncher.launch(Manifest.permission.CAMERA) } ) } @@ -779,13 +836,16 @@ fun ChatBoxEditPanel( onStyleClicked: () -> Unit, onMentionClicked: () -> Unit, onUploadMediaClicked: () -> Unit, - onUploadFileClicked: () -> Unit + onUploadFileClicked: () -> Unit, + onCameraCaptureClicked: () -> Unit ) { var showDropdownMenu by remember { mutableStateOf(false) } Row( - modifier = Modifier.fillMaxWidth().height(52.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -840,6 +900,22 @@ fun ChatBoxEditPanel( paddingStart = 0.dp, paddingEnd = 0.dp ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.chat_box_camera), + color = colorResource(id = R.color.text_primary) + ) + }, + onClick = { + showDropdownMenu = false + onCameraCaptureClicked() + } + ) + Divider( + paddingStart = 0.dp, + paddingEnd = 0.dp + ) DropdownMenuItem( text = { Text( @@ -958,6 +1034,7 @@ fun ChatBoxEditPanelPreview() { onStyleClicked = {}, onAttachObjectClicked = {}, onUploadFileClicked = {}, - onUploadMediaClicked = {} + onUploadMediaClicked = {}, + onCameraCaptureClicked = {} ) } \ No newline at end of file diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatPreviews.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatPreviews.kt index 2d9387c410..afa0640770 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatPreviews.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatPreviews.kt @@ -205,7 +205,8 @@ fun ChatScreenPreview() { onVisibleRangeChanged = { _, _ -> }, onUrlInserted = {}, onGoToMentionClicked = {}, - onShareInviteClicked = {} + onShareInviteClicked = {}, + onImageCaptured = {} ) } diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt index 0c123338ab..beb9b36def 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt @@ -3,7 +3,6 @@ package com.anytypeio.anytype.feature_chats.ui import android.content.res.Configuration import android.net.Uri import android.provider.OpenableColumns -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -70,10 +69,8 @@ import com.anytypeio.anytype.core_models.Block import com.anytypeio.anytype.core_models.Id import com.anytypeio.anytype.core_models.Url import com.anytypeio.anytype.core_ui.foundation.AlertConfig -import com.anytypeio.anytype.core_ui.foundation.AlertIcon import com.anytypeio.anytype.core_ui.foundation.BUTTON_SECONDARY import com.anytypeio.anytype.core_ui.foundation.Divider -import com.anytypeio.anytype.core_ui.foundation.GRADIENT_TYPE_BLUE import com.anytypeio.anytype.core_ui.foundation.GRADIENT_TYPE_RED import com.anytypeio.anytype.core_ui.foundation.GenericAlert import com.anytypeio.anytype.core_ui.foundation.noRippleClickable @@ -252,7 +249,18 @@ fun ChatScreenWrapper( canCreateInviteLink = vm.canCreateInviteLink.collectAsStateWithLifecycle().value, isReadOnly = vm.chatBoxMode .collectAsStateWithLifecycle() - .value is ChatBoxMode.ReadOnly + .value is ChatBoxMode.ReadOnly, + onImageCaptured = { + vm.onChatBoxMediaPicked( + uris = listOf( + ChatViewModel.ChatBoxMediaUri( + uri = it.toString(), + isVideo = false, + capturedByCamera = true + ) + ) + ) + } ) LaunchedEffect(Unit) { vm.uXCommands.collect { command -> @@ -369,6 +377,7 @@ fun ChatScreen( onMarkupLinkClicked: (String) -> Unit, onAttachObjectClicked: () -> Unit, onChatBoxMediaPicked: (List) -> Unit, + onImageCaptured: (Uri) -> Unit, onChatBoxFilePicked: (List) -> Unit, onAddReactionClicked: (String) -> Unit, onViewChatReaction: (Id, String) -> Unit, @@ -803,13 +812,13 @@ fun ChatScreen( }, text = text, spans = spans, - onUrlInserted = onUrlInserted + onUrlInserted = onUrlInserted, + onImageCaptured = onImageCaptured ) } } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun Messages( modifier: Modifier = Modifier, diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 70db930ad7..5fd0c49232 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -2086,5 +2086,7 @@ Please provide specific details of your needs here. There are no spaces yet New messages + Camera + Camera permission denied \ No newline at end of file