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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MessengerApplication"
|
android:name=".MessengerApplication"
|
||||||
@@ -46,6 +47,15 @@
|
|||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -578,6 +578,7 @@ class NetworkMessageRepository @Inject constructor(
|
|||||||
val normalizedMime = mimeType.lowercase()
|
val normalizedMime = mimeType.lowercase()
|
||||||
val normalizedName = fileName.lowercase()
|
val normalizedName = fileName.lowercase()
|
||||||
return when {
|
return when {
|
||||||
|
normalizedName.startsWith("circle_") -> "circle_video"
|
||||||
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image"
|
normalizedMime == "image/gif" || normalizedName.endsWith(".gif") || normalizedName.startsWith("gif_") -> "image"
|
||||||
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image"
|
normalizedName.startsWith("sticker_") || normalizedMime == "image/webp" -> "image"
|
||||||
normalizedMime.startsWith("image/") -> "image"
|
normalizedMime.startsWith("image/") -> "image"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ru.daemonlord.messenger.ui.chat
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@@ -115,6 +116,7 @@ import androidx.annotation.StringRes
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Forward
|
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.ArrowDownward
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
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.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.DeleteOutline
|
import androidx.compose.material.icons.filled.DeleteOutline
|
||||||
@@ -175,6 +178,7 @@ import java.time.LocalDate
|
|||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.io.File
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -231,6 +235,45 @@ fun ChatRoute(
|
|||||||
bytes = picked.bytes,
|
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(
|
ChatScreen(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -251,6 +294,31 @@ fun ChatRoute(
|
|||||||
onCancelComposeAction = viewModel::onCancelComposeAction,
|
onCancelComposeAction = viewModel::onCancelComposeAction,
|
||||||
onLoadMore = viewModel::loadMore,
|
onLoadMore = viewModel::loadMore,
|
||||||
onPickMedia = { pickMediaLauncher.launch("*/*") },
|
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 ->
|
onSendRemoteMedia = { fileName, mimeType, bytes ->
|
||||||
viewModel.onMediaPicked(
|
viewModel.onMediaPicked(
|
||||||
fileName = fileName,
|
fileName = fileName,
|
||||||
@@ -327,6 +395,9 @@ fun ChatScreen(
|
|||||||
onCancelComposeAction: () -> Unit,
|
onCancelComposeAction: () -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
onPickMedia: () -> Unit,
|
onPickMedia: () -> Unit,
|
||||||
|
onCapturePhoto: () -> Unit,
|
||||||
|
onCaptureVideo: () -> Unit,
|
||||||
|
onCaptureCircleVideo: () -> Unit,
|
||||||
onSendRemoteMedia: (String, String, ByteArray) -> Unit,
|
onSendRemoteMedia: (String, String, ByteArray) -> Unit,
|
||||||
onSendPresetMediaUrl: (String) -> Unit,
|
onSendPresetMediaUrl: (String) -> Unit,
|
||||||
onVoiceRecordStart: () -> Unit,
|
onVoiceRecordStart: () -> Unit,
|
||||||
@@ -1600,6 +1671,39 @@ fun ChatScreen(
|
|||||||
disabledIndicatorColor = Color.Transparent,
|
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(
|
Surface(
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
|
||||||
@@ -2130,6 +2234,28 @@ private data class PickedMediaPayload(
|
|||||||
val bytes: ByteArray,
|
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? {
|
private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? {
|
||||||
val resolver = context.contentResolver
|
val resolver = context.contentResolver
|
||||||
val mime = resolver.getType(this)?.ifBlank { null } ?: "application/octet-stream"
|
val mime = resolver.getType(this)?.ifBlank { null } ?: "application/octet-stream"
|
||||||
@@ -2532,7 +2658,10 @@ private fun MessageBubble(
|
|||||||
val fileType = attachment.fileType.lowercase()
|
val fileType = attachment.fileType.lowercase()
|
||||||
when {
|
when {
|
||||||
fileType.startsWith("video/") -> {
|
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)
|
CircleVideoAttachmentPlayer(url = attachment.fileUrl)
|
||||||
} else {
|
} else {
|
||||||
VideoAttachmentCard(
|
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