feat(voice+media): voice recording/playback UI, GIF/sticker support, keyboard content receiver

- ChatComponents: voice recording bar (mic → record → send/cancel), voice playback with MediaPlayer, GIF/WebP via coil-gif + ImageDecoderDecoder, keyboard content receiver for stickers/Bitmoji
- ConversationViewModel: sendVoice wired through
- ConversationScreen: onSendVoice passed to ChatComposer
This commit is contained in:
null 2026-06-24 16:34:48 -05:00
parent c20745e82a
commit 3ad725ca8a
3 changed files with 257 additions and 46 deletions

View File

@ -115,6 +115,7 @@ fun ConversationScreen(
onValueChange = viewModel::updateMessageInput,
onSend = viewModel::sendMessage,
onSendImage = viewModel::sendImage,
onSendVoice = viewModel::sendVoice,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
)
}

View File

@ -109,6 +109,13 @@ class ConversationViewModel @Inject constructor(
}
}
fun sendVoice(audioBytes: ByteArray, durationMs: Long) {
if (currentUserId.isEmpty() || audioBytes.isEmpty()) return
viewModelScope.launch {
runCatching { repository.sendVoiceMessage(coupleId, conversationId, currentUserId, audioBytes, durationMs) }
}
}
suspend fun loadDecryptedMedia(mediaUrl: String): ByteArray? =
repository.loadDecryptedMedia(coupleId, mediaUrl)

View File

@ -2,13 +2,19 @@ package app.closer.ui.messages.components
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.content.MediaType
import androidx.compose.foundation.content.consume
import androidx.compose.foundation.content.contentReceiver
import androidx.compose.foundation.content.hasMediaType
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@ -22,9 +28,13 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -34,7 +44,10 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
@ -43,24 +56,28 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import app.closer.domain.model.QuestionMessage
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.nio.ByteBuffer
/**
* One chat row Messenger style: only the partner's avatar shows (on the left); our own messages
* are bubbles on the right with no avatar. Handles text and encrypted image messages.
* are bubbles on the right with no avatar. Handles text, encrypted image/GIF, and voice messages.
*/
@Composable
fun ChatMessageRow(
@ -86,10 +103,10 @@ fun ChatMessageRow(
Spacer(modifier = Modifier.width(6.dp))
}
if (message.isImage) {
EncryptedChatImage(mediaUrl = message.mediaUrl, shape = bubbleShape, loadDecryptedMedia = loadDecryptedMedia)
} else {
Surface(
when {
message.isImage -> EncryptedChatImage(message.mediaUrl, bubbleShape, loadDecryptedMedia)
message.isVoice -> EncryptedVoiceMessage(message.mediaUrl, message.durationMs, isCurrentUser, bubbleShape, loadDecryptedMedia)
else -> Surface(
shape = bubbleShape,
color = if (isCurrentUser) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceVariant,
@ -107,29 +124,42 @@ fun ChatMessageRow(
}
}
@Composable
private fun rememberMediaImageLoader(): ImageLoader {
val context = LocalContext.current
return remember {
ImageLoader.Builder(context)
.components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(ImageDecoderDecoder.Factory())
else add(GifDecoder.Factory())
}
.build()
}
}
@Composable
private fun EncryptedChatImage(
mediaUrl: String,
shape: Shape,
loadDecryptedMedia: suspend (String) -> ByteArray?
) {
val image by produceState<ImageBitmap?>(initialValue = null, mediaUrl) {
val bytes = loadDecryptedMedia(mediaUrl)
value = bytes?.let {
runCatching { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }.getOrNull()
}
val bytes by produceState<ByteArray?>(initialValue = null, mediaUrl) {
value = loadDecryptedMedia(mediaUrl)
}
val loader = rememberMediaImageLoader()
val context = LocalContext.current
Box(
modifier = Modifier
.widthIn(max = 230.dp)
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.widthIn(max = 230.dp).clip(shape).background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
val bmp = image
if (bmp != null) {
Image(
bitmap = bmp,
val b = bytes
if (b != null) {
val model = remember(b) {
ImageRequest.Builder(context).data(ByteBuffer.wrap(b)).build()
}
AsyncImage(
model = model,
imageLoader = loader,
contentDescription = "Photo",
contentScale = ContentScale.Fit,
modifier = Modifier.widthIn(max = 230.dp)
@ -146,6 +176,82 @@ private fun EncryptedChatImage(
}
}
@Composable
private fun EncryptedVoiceMessage(
mediaUrl: String,
durationMs: Long,
isCurrentUser: Boolean,
shape: Shape,
loadDecryptedMedia: suspend (String) -> ByteArray?
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var player by remember { mutableStateOf<MediaPlayer?>(null) }
var tempFile by remember { mutableStateOf<File?>(null) }
var playing by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
DisposableEffect(Unit) {
onDispose {
runCatching { player?.release() }
tempFile?.delete()
}
}
fun toggle() {
val p = player
if (p != null) {
if (p.isPlaying) { p.pause(); playing = false } else { p.start(); playing = true }
return
}
if (loading) return
loading = true
scope.launch {
val data = loadDecryptedMedia(mediaUrl)
if (data == null) { loading = false; return@launch }
val f = withContext(Dispatchers.IO) {
File.createTempFile("voice_play", ".m4a", context.cacheDir).apply { writeBytes(data) }
}
tempFile = f
val mp = MediaPlayer()
runCatching {
mp.setDataSource(f.absolutePath)
mp.setOnCompletionListener { playing = false; runCatching { mp.seekTo(0) } }
mp.prepare()
mp.start()
}.onSuccess { player = mp; playing = true }.onFailure { mp.release() }
loading = false
}
}
Surface(
shape = shape,
color = if (isCurrentUser) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 230.dp)
) {
val tint = if (isCurrentUser) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = { toggle() }, modifier = Modifier.size(36.dp)) {
if (loading) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp), color = tint)
} else {
Icon(
imageVector = if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (playing) "Pause" else "Play",
tint = tint
)
}
}
Icon(Icons.Filled.Mic, contentDescription = null, tint = tint.copy(alpha = 0.7f), modifier = Modifier.size(16.dp))
Text(text = formatDuration(durationMs), style = MaterialTheme.typography.bodyMedium, color = tint)
}
}
}
@Composable
private fun ChatAvatar(url: String?, visible: Boolean) {
val size = 28.dp
@ -165,23 +271,23 @@ private fun ChatAvatar(url: String?, visible: Boolean) {
modifier = Modifier.size(size).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
Icon(Icons.Filled.Person, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
}
}
/** Composer with text + gallery (images-only) + camera. */
/**
* Composer: text + gallery + camera (images only), GIFs/stickers/Bitmoji inserted from the
* keyboard (rich content), and hold-free voice notes. All media is E2E-encrypted before sending.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatComposer(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onSendImage: (ByteArray) -> Unit,
onSendVoice: (ByteArray, Long) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
@ -211,31 +317,103 @@ fun ChatComposer(
pendingCameraUri = uri
cameraLauncher.launch(uri)
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted: Boolean -> if (granted) launchCamera() }
// ── Voice recording ──────────────────────────────────────────────────────────
var isRecording by remember { mutableStateOf(false) }
var recordStart by remember { mutableLongStateOf(0L) }
var elapsedMs by remember { mutableLongStateOf(0L) }
val recorder = remember { mutableStateOf<MediaRecorder?>(null) }
val recordFile = remember { mutableStateOf<File?>(null) }
fun startRecording() {
val f = File(context.cacheDir, "voice_${System.currentTimeMillis()}.m4a")
val rec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(context) else @Suppress("DEPRECATION") MediaRecorder()
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setOutputFile(f.absolutePath)
runCatching { rec.prepare(); rec.start() }
.onSuccess {
recorder.value = rec; recordFile.value = f
recordStart = System.currentTimeMillis(); elapsedMs = 0L; isRecording = true
}
.onFailure { runCatching { rec.release() }; f.delete() }
}
fun finishRecording(send: Boolean) {
val rec = recorder.value
val f = recordFile.value
val dur = System.currentTimeMillis() - recordStart
runCatching { rec?.stop() }
runCatching { rec?.release() }
recorder.value = null
recordFile.value = null
isRecording = false
if (send && f != null && dur > 800) {
scope.launch {
val bytes = withContext(Dispatchers.IO) { runCatching { f.readBytes() }.getOrNull() }
f.delete()
bytes?.takeIf { it.isNotEmpty() }?.let { onSendVoice(it, dur) }
}
} else {
f?.delete()
}
}
val micPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted: Boolean -> if (granted) startRecording() }
LaunchedEffect(isRecording) {
while (isRecording) { elapsedMs = System.currentTimeMillis() - recordStart; delay(200) }
}
DisposableEffect(Unit) {
onDispose { runCatching { recorder.value?.release() }; recordFile.value?.delete() }
}
if (isRecording) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = { finishRecording(send = false) }, modifier = Modifier.size(44.dp)) {
Icon(Icons.Filled.Close, contentDescription = "Cancel", tint = MaterialTheme.colorScheme.error)
}
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(MaterialTheme.colorScheme.error))
Text(
text = "Recording… ${formatDuration(elapsedMs)}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { finishRecording(send = true) }, modifier = Modifier.size(48.dp)) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary)
}
}
return
}
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
IconButton(
onClick = {
galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
modifier = Modifier.size(42.dp)
onClick = { galleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
modifier = Modifier.size(40.dp)
) {
Icon(Icons.Filled.Image, contentDescription = "Send a photo", tint = MaterialTheme.colorScheme.primary)
}
IconButton(
onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
) launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED)
launchCamera() else cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
},
modifier = Modifier.size(42.dp)
modifier = Modifier.size(40.dp)
) {
Icon(Icons.Filled.PhotoCamera, contentDescription = "Take a photo", tint = MaterialTheme.colorScheme.primary)
}
@ -243,7 +421,17 @@ fun ChatComposer(
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
// Accept GIFs / stickers / Bitmoji inserted from the keyboard's pickers.
modifier = Modifier.weight(1f).contentReceiver { transferableContent ->
if (!transferableContent.hasMediaType(MediaType.Image)) {
transferableContent
} else {
transferableContent.consume { item ->
val uri = item.uri
if (uri != null) { readAndSend(uri); true } else false
}
}
},
placeholder = {
Text(
text = "Message…",
@ -264,13 +452,28 @@ fun ChatComposer(
textStyle = MaterialTheme.typography.bodyMedium
)
IconButton(onClick = onSend, enabled = value.isNotBlank(), modifier = Modifier.size(48.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (value.isNotBlank()) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
if (value.isBlank()) {
// Mic when there's nothing typed (tap to record a voice note).
IconButton(
onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
startRecording() else micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
},
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Filled.Mic, contentDescription = "Record a voice note", tint = MaterialTheme.colorScheme.primary)
}
} else {
IconButton(onClick = onSend, modifier = Modifier.size(48.dp)) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send", tint = MaterialTheme.colorScheme.primary)
}
}
}
}
private fun formatDuration(ms: Long): String {
val totalSec = (ms / 1000).toInt()
val m = totalSec / 60
val s = totalSec % 60
return "%d:%02d".format(m, s)
}