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