From 2fa006747d01b7a89ef8e800474be7225f4ff65e Mon Sep 17 00:00:00 2001 From: benya Date: Wed, 11 Mar 2026 22:19:10 +0300 Subject: [PATCH] android: add circle recording and in-app camera capture --- android/app/src/main/AndroidManifest.xml | 10 ++ .../repository/NetworkMessageRepository.kt | 1 + .../messenger/ui/chat/ChatScreen.kt | 131 +++++++++++++++++- android/app/src/main/res/xml/file_paths.xml | 6 + 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/res/xml/file_paths.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 428bb19..73912d3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + + + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index fc15b6a..31d1ca1 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -578,6 +578,7 @@ class NetworkMessageRepository @Inject constructor( val normalizedMime = mimeType.lowercase() val normalizedName = fileName.lowercase() return when { + normalizedName.startsWith("circle_") -> "circle_video" normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image" normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image" normalizedMime.startsWith("image/") -> "image" diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 93af5a0..d57f77e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -2,6 +2,7 @@ package ru.daemonlord.messenger.ui.chat import android.Manifest import android.app.Activity +import android.graphics.Bitmap import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -115,6 +116,7 @@ import androidx.annotation.StringRes import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.core.view.WindowCompat import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Forward @@ -123,6 +125,7 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.AddAPhoto import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DeleteOutline @@ -175,6 +178,7 @@ import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit +import java.io.File import java.util.Locale import java.net.URLEncoder import kotlinx.coroutines.delay @@ -231,6 +235,45 @@ fun ChatRoute( bytes = picked.bytes, ) } + val takePhotoLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicturePreview(), + ) { bitmap -> + val picked = bitmap?.toJpegPayload() ?: return@rememberLauncherForActivityResult + viewModel.onMediaPicked( + fileName = "camera_photo_${System.currentTimeMillis()}.jpg", + mimeType = picked.mimeType, + bytes = picked.bytes, + ) + } + var pendingCaptureVideoUri by remember { mutableStateOf(null) } + var pendingCaptureVideoFile by remember { mutableStateOf(null) } + var pendingCaptureCircle by remember { mutableStateOf(false) } + val captureVideoLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CaptureVideo(), + ) { success -> + val uri = pendingCaptureVideoUri + val file = pendingCaptureVideoFile + val asCircle = pendingCaptureCircle + pendingCaptureVideoUri = null + pendingCaptureVideoFile = null + pendingCaptureCircle = false + if (success && uri != null) { + val picked = uri.readMediaPayload(context) + if (picked != null) { + val fileName = if (asCircle) { + "circle_${System.currentTimeMillis()}.mp4" + } else { + "camera_video_${System.currentTimeMillis()}.mp4" + } + viewModel.onMediaPicked( + fileName = fileName, + mimeType = if (picked.mimeType.startsWith("video/")) picked.mimeType else "video/mp4", + bytes = picked.bytes, + ) + } + } + runCatching { file?.delete() } + } ChatScreen( state = state, @@ -251,6 +294,31 @@ fun ChatRoute( onCancelComposeAction = viewModel::onCancelComposeAction, onLoadMore = viewModel::loadMore, onPickMedia = { pickMediaLauncher.launch("*/*") }, + onCapturePhoto = { takePhotoLauncher.launch(null) }, + onCaptureVideo = { + val file = createTempCaptureFile(context = context, prefix = "camera_video_", suffix = ".mp4") + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file, + ) + pendingCaptureCircle = false + pendingCaptureVideoFile = file + pendingCaptureVideoUri = uri + captureVideoLauncher.launch(uri) + }, + onCaptureCircleVideo = { + val file = createTempCaptureFile(context = context, prefix = "circle_", suffix = ".mp4") + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file, + ) + pendingCaptureCircle = true + pendingCaptureVideoFile = file + pendingCaptureVideoUri = uri + captureVideoLauncher.launch(uri) + }, onSendRemoteMedia = { fileName, mimeType, bytes -> viewModel.onMediaPicked( fileName = fileName, @@ -327,6 +395,9 @@ fun ChatScreen( onCancelComposeAction: () -> Unit, onLoadMore: () -> Unit, onPickMedia: () -> Unit, + onCapturePhoto: () -> Unit, + onCaptureVideo: () -> Unit, + onCaptureCircleVideo: () -> Unit, onSendRemoteMedia: (String, String, ByteArray) -> Unit, onSendPresetMediaUrl: (String) -> Unit, onVoiceRecordStart: () -> Unit, @@ -1600,6 +1671,39 @@ fun ChatScreen( disabledIndicatorColor = Color.Transparent, ), ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + ) { + IconButton( + onClick = onCapturePhoto, + enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice, + ) { + Icon(imageVector = Icons.Filled.AddAPhoto, contentDescription = "Capture photo") + } + } + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + ) { + IconButton( + onClick = onCaptureVideo, + enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice, + ) { + Icon(imageVector = Icons.Filled.Movie, contentDescription = "Capture video") + } + } + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + ) { + IconButton( + onClick = onCaptureCircleVideo, + enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice, + ) { + Icon(imageVector = Icons.Filled.RadioButtonChecked, contentDescription = "Record circle video") + } + } Surface( shape = CircleShape, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), @@ -2130,6 +2234,28 @@ private data class PickedMediaPayload( val bytes: ByteArray, ) +private fun Bitmap.toJpegPayload(quality: Int = 92): PickedMediaPayload? { + val stream = java.io.ByteArrayOutputStream() + val ok = compress(Bitmap.CompressFormat.JPEG, quality.coerceIn(1, 100), stream) + if (!ok) return null + val bytes = stream.toByteArray() + if (bytes.isEmpty()) return null + return PickedMediaPayload( + fileName = "camera_photo_${System.currentTimeMillis()}.jpg", + mimeType = "image/jpeg", + bytes = bytes, + ) +} + +private fun createTempCaptureFile( + context: Context, + prefix: String, + suffix: String, +): File { + val dir = File(context.cacheDir, "captures").apply { mkdirs() } + return File.createTempFile(prefix, suffix, dir) +} + private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? { val resolver = context.contentResolver val mime = resolver.getType(this)?.ifBlank { null } ?: "application/octet-stream" @@ -2532,7 +2658,10 @@ private fun MessageBubble( val fileType = attachment.fileType.lowercase() when { fileType.startsWith("video/") -> { - if (message.type.contains("video_note", ignoreCase = true)) { + if ( + message.type.contains("video_note", ignoreCase = true) || + message.type.contains("circle_video", ignoreCase = true) + ) { CircleVideoAttachmentPlayer(url = attachment.fileUrl) } else { VideoAttachmentCard( diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..c448804 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + +