android: add hold-to-record voice flow with lock cancel and audio focus
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 16:15:43 +03:00
parent 69c0b632df
commit 47190e354d
8 changed files with 379 additions and 10 deletions

View File

@@ -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.

View File

@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".MessengerApplication"

View File

@@ -0,0 +1,20 @@
package ru.daemonlord.messenger.core.audio
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
object AppAudioFocusCoordinator {
private val _activeSourceId = MutableStateFlow<String?>(null)
val activeSourceId: StateFlow<String?> = _activeSourceId.asStateFlow()
fun request(sourceId: String) {
_activeSourceId.value = sourceId
}
fun release(sourceId: String) {
if (_activeSourceId.value == sourceId) {
_activeSourceId.value = null
}
}
}

View File

@@ -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<Int?>(null) }
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
var actionMenuMessage by remember { mutableStateOf<MessageItem?>(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()
if (canSend) {
Button(
onClick = { if (canSend) onSendClick() },
onClick = onSendClick,
enabled = state.canSendMessages && !state.isUploadingMedia,
) {
Text(if (canSend) "\u27A4" else "\uD83C\uDFA4")
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(
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

View File

@@ -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
}
}

View File

@@ -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(),
)

View File

@@ -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
}
}

View File

@@ -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