android: add circle recording and in-app camera capture
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
6
android/app/src/main/res/xml/file_paths.xml
Normal file
6
android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
Reference in New Issue
Block a user