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 @@
+
+
+
+