diff --git a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt index 1a9f92f9..4a0bfd5d 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationScreen.kt @@ -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) ) } diff --git a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt index 0df50319..582e2893 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt @@ -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) diff --git a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt index 4344d0b4..7755f112 100644 --- a/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt +++ b/app/src/main/java/app/closer/ui/messages/components/ChatComponents.kt @@ -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(initialValue = null, mediaUrl) { - val bytes = loadDecryptedMedia(mediaUrl) - value = bytes?.let { - runCatching { BitmapFactory.decodeByteArray(it, 0, it.size)?.asImageBitmap() }.getOrNull() - } + val bytes by produceState(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(null) } + var tempFile by remember { mutableStateOf(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(null) } + val recordFile = remember { mutableStateOf(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) +}