android: add circle recording and in-app camera capture
Some checks failed
Android CI / android (push) Has started running
CI / test (push) Has been cancelled
Android Release / release (push) Failing after 37s

This commit is contained in:
2026-03-11 22:19:10 +03:00
parent 4032b55b0b
commit 2fa006747d
4 changed files with 147 additions and 1 deletions

View File

@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:name=".MessengerApplication"
@@ -46,6 +47,15 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

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

View File

@@ -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<Uri?>(null) }
var pendingCaptureVideoFile by remember { mutableStateOf<File?>(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(

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="captures"
path="captures/" />
</paths>