android: add hold-to-record voice flow with lock cancel and audio focus
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user