diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 83723aa..078b2ee 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -392,3 +392,15 @@ - Reworked Settings/Profile screens from placeholders to editable account management screens. - Added avatar upload with center square crop (`1:1`) before upload. - Upgraded message attachment rendering with in-bubble multi-image gallery and unified attachment context actions (open/copy/close). + +### Step 66 - Voice recording controls + global audio focus +- Added microphone permission (`RECORD_AUDIO`) and in-chat voice recording flow based on press-and-hold gesture. +- Implemented Telegram-like gesture controls for voice button: + - hold to record, + - slide up to lock recording, + - slide left to cancel recording. +- Added minimum voice length validation (`>= 1s`) before sending. +- Integrated voice message sending via existing media upload path (`audio/mp4` attachment). +- Added process-wide audio focus coordinator to enforce single active audio source: + - attachment player pauses when another source starts, + - recording requests focus and stops competing playback. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 554c6d6..0eacc97 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + (null) + val activeSourceId: StateFlow = _activeSourceId.asStateFlow() + + fun request(sourceId: String) { + _activeSourceId.value = sourceId + } + + fun release(sourceId: String) { + if (_activeSourceId.value == sourceId) { + _activeSourceId.value = null + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 74fb649..93f8a8b 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -1,6 +1,8 @@ package ru.daemonlord.messenger.ui.chat +import android.Manifest import android.content.Context +import android.content.pm.PackageManager import android.media.AudioAttributes import android.media.MediaPlayer import android.net.Uri @@ -11,6 +13,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.Arrangement @@ -54,15 +58,22 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.platform.LocalClipboardManager import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.core.content.ContextCompat import coil.compose.AsyncImage +import kotlinx.coroutines.flow.collectLatest +import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator import ru.daemonlord.messenger.domain.message.model.MessageItem +import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import kotlinx.coroutines.delay +import kotlin.math.roundToInt @Composable fun ChatRoute( @@ -71,6 +82,26 @@ fun ChatRoute( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val voiceRecorder = remember(context) { VoiceRecorder(context) } + var hasAudioPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + ) + } + val audioPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + hasAudioPermission = granted + } + DisposableEffect(voiceRecorder) { + onDispose { + voiceRecorder.cancel() + AppAudioFocusCoordinator.release("voice-recording") + } + } val pickMediaLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), ) { uri -> @@ -101,6 +132,43 @@ fun ChatRoute( onCancelComposeAction = viewModel::onCancelComposeAction, onLoadMore = viewModel::loadMore, onPickMedia = { pickMediaLauncher.launch("*/*") }, + onVoiceRecordStart = { + if (!hasAudioPermission) { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } else { + val started = voiceRecorder.start() + if (started) { + AppAudioFocusCoordinator.request("voice-recording") + viewModel.onVoiceRecordStarted() + } else { + viewModel.onVoiceRecordCancelled() + } + } + }, + onVoiceRecordTick = { + viewModel.onVoiceRecordTick(voiceRecorder.elapsedMillis()) + }, + onVoiceRecordLock = viewModel::onVoiceRecordLocked, + onVoiceRecordCancel = { + voiceRecorder.cancel() + AppAudioFocusCoordinator.release("voice-recording") + viewModel.onVoiceRecordCancelled() + }, + onVoiceRecordSend = { + val duration = voiceRecorder.elapsedMillis() + val bytes = voiceRecorder.stopAndReadBytes() + AppAudioFocusCoordinator.release("voice-recording") + if (bytes != null && bytes.isNotEmpty()) { + viewModel.onVoiceRecordFinish( + fileName = "voice_${System.currentTimeMillis()}.m4a", + mimeType = "audio/mp4", + bytes = bytes, + durationMs = duration, + ) + } else { + viewModel.onVoiceRecordCancelled() + } + }, ) } @@ -124,6 +192,11 @@ fun ChatScreen( onCancelComposeAction: () -> Unit, onLoadMore: () -> Unit, onPickMedia: () -> Unit, + onVoiceRecordStart: () -> Unit, + onVoiceRecordTick: () -> Unit, + onVoiceRecordLock: () -> Unit, + onVoiceRecordCancel: () -> Unit, + onVoiceRecordSend: () -> Unit, ) { val allImageUrls = remember(state.messages) { state.messages @@ -136,6 +209,14 @@ fun ChatScreen( var viewerImageIndex by remember { mutableStateOf(null) } var dismissedPinnedMessageId by remember { mutableStateOf(null) } var actionMenuMessage by remember { mutableStateOf(null) } + + LaunchedEffect(state.isRecordingVoice) { + if (!state.isRecordingVoice) return@LaunchedEffect + while (state.isRecordingVoice) { + onVoiceRecordTick() + delay(120) + } + } Column( modifier = Modifier .fillMaxSize() @@ -483,7 +564,7 @@ fun ChatScreen( ) Button( onClick = onPickMedia, - enabled = state.canSendMessages && !state.isUploadingMedia, + enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice, ) { Text(if (state.isUploadingMedia) "..." else "\uD83D\uDCCE") } @@ -491,11 +572,47 @@ fun ChatScreen( !state.isSending && !state.isUploadingMedia && state.inputText.isNotBlank() - Button( - onClick = { if (canSend) onSendClick() }, - enabled = state.canSendMessages && !state.isUploadingMedia, + if (canSend) { + Button( + onClick = onSendClick, + enabled = state.canSendMessages && !state.isUploadingMedia, + ) { + Text("\u27A4") + } + } else { + VoiceHoldToRecordButton( + enabled = state.canSendMessages && !state.isUploadingMedia, + isRecording = state.isRecordingVoice, + isLocked = state.isVoiceLocked, + onStart = onVoiceRecordStart, + onLock = onVoiceRecordLock, + onCancel = onVoiceRecordCancel, + onRelease = onVoiceRecordSend, + ) + } + } + if (state.isRecordingVoice) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text(if (canSend) "\u27A4" else "\uD83C\uDFA4") + Text( + text = "Voice ${formatDuration(state.voiceRecordingDurationMs.toInt())}", + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = state.voiceRecordingHint ?: "", + style = MaterialTheme.typography.labelSmall, + ) + if (state.isVoiceLocked) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Button(onClick = onVoiceRecordCancel) { Text("Cancel") } + Button(onClick = onVoiceRecordSend) { Text("Send") } + } + } } } } @@ -967,11 +1084,21 @@ private fun AudioAttachmentPlayer(url: String) { setOnCompletionListener { isPlaying = false positionMs = durationMs + AppAudioFocusCoordinator.release("player:$url") } setDataSource(url) prepareAsync() } } + LaunchedEffect(url) { + AppAudioFocusCoordinator.activeSourceId.collectLatest { activeId -> + val currentId = "player:$url" + if (activeId != null && activeId != currentId && isPlaying) { + runCatching { mediaPlayer.pause() } + isPlaying = false + } + } + } LaunchedEffect(isPlaying, isPrepared) { if (!isPlaying || !isPrepared) return@LaunchedEffect while (isPlaying) { @@ -984,6 +1111,7 @@ private fun AudioAttachmentPlayer(url: String) { runCatching { mediaPlayer.stop() } + AppAudioFocusCoordinator.release("player:$url") mediaPlayer.release() } } @@ -1004,7 +1132,9 @@ private fun AudioAttachmentPlayer(url: String) { if (isPlaying) { mediaPlayer.pause() isPlaying = false + AppAudioFocusCoordinator.release("player:$url") } else { + AppAudioFocusCoordinator.request("player:$url") mediaPlayer.start() isPlaying = true } @@ -1034,6 +1164,61 @@ private fun AudioAttachmentPlayer(url: String) { } } +@Composable +private fun VoiceHoldToRecordButton( + enabled: Boolean, + isRecording: Boolean, + isLocked: Boolean, + onStart: () -> Unit, + onLock: () -> Unit, + onCancel: () -> Unit, + onRelease: () -> Unit, +) { + Box( + modifier = Modifier + .pointerInput(enabled, isRecording, isLocked) { + if (!enabled || isLocked) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown() + onStart() + var cancelledBySlide = false + var lockedBySlide = false + while (true) { + val event = awaitPointerEvent() + val change = event.changes.first() + val delta = change.position - down.position + if (!lockedBySlide && delta.y < -120f) { + lockedBySlide = true + onLock() + } + if (!lockedBySlide && delta.x < -150f) { + cancelledBySlide = true + onCancel() + break + } + if (!change.pressed) { + if (!cancelledBySlide && !lockedBySlide) { + onRelease() + } + break + } + if (change.positionChange() != androidx.compose.ui.geometry.Offset.Zero) { + change.consume() + } + } + } + }, + ) { + Button( + onClick = {}, + enabled = enabled, + modifier = Modifier, + ) { + Text("\uD83C\uDFA4") + } + } +} + private fun formatDuration(ms: Int): String { if (ms <= 0) return "0:00" val total = ms / 1000 diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index 827a9f3..73ff567 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -79,6 +79,79 @@ class ChatViewModel @Inject constructor( _uiState.update { it.copy(inputText = value) } } + fun onVoiceRecordStarted() { + _uiState.update { + it.copy( + isRecordingVoice = true, + isVoiceLocked = false, + voiceRecordingDurationMs = 0L, + voiceRecordingHint = "Slide up to lock, slide left to cancel", + errorMessage = null, + ) + } + } + + fun onVoiceRecordTick(durationMs: Long) { + _uiState.update { + if (!it.isRecordingVoice) it else it.copy(voiceRecordingDurationMs = durationMs) + } + } + + fun onVoiceRecordLocked() { + _uiState.update { + if (!it.isRecordingVoice) it else { + it.copy( + isVoiceLocked = true, + voiceRecordingHint = "Recording locked", + ) + } + } + } + + fun onVoiceRecordCancelled() { + _uiState.update { + it.copy( + isRecordingVoice = false, + isVoiceLocked = false, + voiceRecordingDurationMs = 0L, + voiceRecordingHint = null, + ) + } + } + + fun onVoiceRecordFinish( + fileName: String, + mimeType: String, + bytes: ByteArray, + durationMs: Long, + ) { + if (durationMs < MIN_VOICE_DURATION_MS) { + _uiState.update { + it.copy( + isRecordingVoice = false, + isVoiceLocked = false, + voiceRecordingDurationMs = 0L, + voiceRecordingHint = null, + errorMessage = "Voice message is too short.", + ) + } + return + } + _uiState.update { + it.copy( + isRecordingVoice = false, + isVoiceLocked = false, + voiceRecordingDurationMs = 0L, + voiceRecordingHint = null, + ) + } + onMediaPicked( + fileName = fileName, + mimeType = mimeType, + bytes = bytes, + ) + } + fun onSelectMessage(message: MessageItem?) { if (message == null) { onClearSelection() @@ -606,5 +679,6 @@ class ChatViewModel @Inject constructor( private companion object { const val MESSAGES_PAGE_SIZE = 50 + const val MIN_VOICE_DURATION_MS = 1_000L } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt index d9fc0ff..0e2b0c9 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -28,6 +28,10 @@ data class MessageUiState( val isForwarding: Boolean = false, val canSendMessages: Boolean = true, val sendRestrictionText: String? = null, + val isRecordingVoice: Boolean = false, + val isVoiceLocked: Boolean = false, + val voiceRecordingDurationMs: Long = 0L, + val voiceRecordingHint: String? = null, val actionState: MessageActionState = MessageActionState(), ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt new file mode 100644 index 0000000..0b7f478 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt @@ -0,0 +1,73 @@ +package ru.daemonlord.messenger.ui.chat.voice + +import android.content.Context +import android.media.MediaRecorder +import java.io.File +import java.util.UUID + +class VoiceRecorder(private val context: Context) { + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + private var startedAtMillis: Long = 0L + + fun start(): Boolean { + return runCatching { + val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a") + val mediaRecorder = MediaRecorder(context).apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioEncodingBitRate(64_000) + setAudioSamplingRate(44_100) + setOutputFile(file.absolutePath) + prepare() + start() + } + recorder = mediaRecorder + outputFile = file + startedAtMillis = System.currentTimeMillis() + true + }.getOrElse { + releaseInternal(deleteFile = true) + false + } + } + + fun elapsedMillis(nowMillis: Long = System.currentTimeMillis()): Long { + if (startedAtMillis <= 0L) return 0L + return (nowMillis - startedAtMillis).coerceAtLeast(0L) + } + + fun stopAndReadBytes(): ByteArray? { + val file = outputFile ?: return null + val success = runCatching { + recorder?.stop() + true + }.getOrDefault(false) + releaseInternal(deleteFile = false) + if (!success || !file.exists()) { + file.delete() + return null + } + return runCatching { + val bytes = file.readBytes() + file.delete() + bytes + }.getOrNull() + } + + fun cancel() { + releaseInternal(deleteFile = true) + } + + private fun releaseInternal(deleteFile: Boolean) { + runCatching { recorder?.reset() } + runCatching { recorder?.release() } + recorder = null + startedAtMillis = 0L + if (deleteFile) { + outputFile?.delete() + } + outputFile = null + } +} diff --git a/docs/android-checklist.md b/docs/android-checklist.md index 598404a..261034c 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -67,11 +67,11 @@ - [ ] Circle video playback (view-only при необходимости) ## 9. Запись голосовых -- [ ] Hold-to-record -- [ ] Slide up to lock -- [ ] Slide left to cancel -- [ ] Минимальная длина записи (>=1s) -- [ ] Единый global audio focus (1 источник звука) +- [x] Hold-to-record +- [x] Slide up to lock +- [x] Slide left to cancel +- [x] Минимальная длина записи (>=1s) +- [x] Единый global audio focus (1 источник звука) ## 10. Группы/каналы - [ ] Create group/channel