android: PTT swipe-to-lock, recording overlay with level bar, cancel/send buttons

- voice_recorder: peak_level tracking in feed(), voice_recorder_get_peak_level()
- bridge/JNI: voice_get_peak_level API
- ViewModel: isRecordingLocked, recordingDurationMs with 80ms timer, lock/send/cancel
- InputBar: 3 states (idle/holding/locked), custom pointerInput gesture with swipe-up lock
- ChatScreen: poll peak level 12x/sec during recording
This commit is contained in:
evgeny committed 2026-07-28 23:28:19 +03:00
1 parent 0f1e6e0006
commit 9b6dd6476c
8 files changed
+184 -47

No files matched your search

@@ -87,6 +87,7 @@ object NativeLib {
fun voiceGetPreset(): Int = nativeVoiceGetPreset()
fun voiceSetCompressor(enabled: Boolean) { nativeVoiceSetCompressor(enabled) }
fun voiceGetCompressor(): Boolean = nativeVoiceGetCompressor()
fun voiceGetPeakLevel(): Float = nativeVoiceGetPeakLevel()
/* ── Attachment ── */
fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath)
@@ -128,6 +129,7 @@ object NativeLib {
private external fun nativeVoiceGetPreset(): Int
private external fun nativeVoiceSetCompressor(enabled: Boolean)
private external fun nativeVoiceGetCompressor(): Boolean
private external fun nativeVoiceGetPeakLevel(): Float
/* ── Attachment JNI ── */
private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean
@@ -5,7 +5,7 @@ import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
@@ -17,11 +17,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import kotlinx.coroutines.delay
@Composable
@@ -75,6 +77,8 @@ fun MessageBubble(message: Message) {
}
}
private val LOCK_SWIPE_THRESHOLD_DP = 80f
@Composable
fun InputBar(
text: String,
@@ -82,13 +86,20 @@ fun InputBar(
onSend: () -> Unit,
enabled: Boolean = true,
isRecording: Boolean = false,
isRecordingLocked: Boolean = false,
recordingDurationMs: Int = 0,
recordingLevel: Float = 0f,
onPttPressed: () -> Unit = {},
onPttReleased: () -> Unit = {},
onPttLock: () -> Unit = {},
onVoiceSend: () -> Unit = {},
onVoiceCancel: () -> Unit = {},
onAttachClicked: () -> Unit = {},
modifier: Modifier = Modifier
) {
val ctx = LocalContext.current
val density = LocalDensity.current
val lockThresholdPx = with(density) { LOCK_SWIPE_THRESHOLD_DP.dp.toPx() }
var hasMicPerm by remember {
mutableStateOf(
@@ -100,21 +111,23 @@ fun InputBar(
hasMicPerm = granted
}
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { onAttachClicked() }
}
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { _ -> }
Row(
modifier = modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
/* Attach button */
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) {
Text("\uD83D\uDCCE", fontSize = 20.sp)
if (!isRecording) {
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) {
Text("\uD83D\uDCCE", fontSize = 20.sp)
}
} else {
Spacer(Modifier.width(4.dp))
}
/* Recording overlay or text input */
if (isRecording) {
/* Recording overlay */
Surface(
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(8.dp),
@@ -122,23 +135,25 @@ fun InputBar(
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
fontSize = 16.sp,
modifier = Modifier.weight(1f)
)
val lvlPct = (recordingLevel * 40f).coerceIn(0f, 1f)
LinearProgressIndicator(
progress = { lvlPct },
modifier = Modifier.width(60.dp).height(4.dp).padding(end = 8.dp),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.3f),
)
/* Cancel button — tap to cancel recording */
IconButton(onClick = onPttReleased) {
Text("\u2715", color = Color.White, fontSize = 16.sp)
}
}
}
} else {
/* Text input */
OutlinedTextField(
value = text,
onValueChange = onTextChange,
@@ -151,28 +166,69 @@ fun InputBar(
Spacer(Modifier.width(4.dp))
/* PTT button — press to talk */
IconButton(
onClick = {
if (!hasMicPerm) permLauncher.launch(Manifest.permission.RECORD_AUDIO)
},
enabled = enabled && hasMicPerm,
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (isRecording && isRecordingLocked) {
/* Locked: cancel + send voice */
IconButton(onClick = onVoiceCancel) {
Text("\u2715", fontSize = 20.sp, color = Color(0xFFE53935))
}
IconButton(onClick = onVoiceSend) {
Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50))
}
} else if (isRecording) {
/* Holding: only PTT active */
IconButton(
onClick = {},
enabled = true,
modifier = Modifier.background(Color(0xFFE53935), RoundedCornerShape(4.dp))
) {
Text("\uD83C\uDFA4", fontSize = 20.sp)
}
} else {
/* Idle: PTT + send */
IconButton(
onClick = {
if (!hasMicPerm) { permLauncher.launch(Manifest.permission.RECORD_AUDIO); return@IconButton }
},
enabled = true,
modifier = Modifier.pointerInput(Unit) {
awaitPointerEventScope {
/* wait for the first down */
var event = awaitPointerEvent()
var change = event.changes.firstOrNull() ?: return@awaitPointerEventScope
if (!change.pressed) return@awaitPointerEventScope
change.consume()
if (!hasMicPerm) return@awaitPointerEventScope
onPttPressed()
tryAwaitRelease()
var locked = false
var totalDragY = 0f
var lastY = change.position.y
while (true) {
event = awaitPointerEvent()
change = event.changes.firstOrNull() ?: break
if (change.pressed) {
val dy = change.position.y - lastY
lastY = change.position.y
totalDragY += dy
if (totalDragY < -lockThresholdPx && !locked) {
locked = true; onPttLock()
}
change.consume()
} else {
change.consume(); break
}
}
onPttReleased()
}
)
}
) {
Text("\uD83C\uDFA4", fontSize = 20.sp)
}
) {
Text("\uD83C\uDFA4", fontSize = 20.sp)
}
/* Send button */
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) {
Text("Send")
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) {
Text("Send")
}
}
}
}
@@ -184,7 +240,7 @@ private fun formatDuration(ms: Int): String {
private fun formatTime(ts: Long): String {
if (ts == 0L) return ""
val s = ts / 1_000_000_000 // nanoseconds -> seconds
val s = ts / 1_000_000_000
val h = (s / 3600) % 24
val m = (s / 60) % 60
return "%02d:%02d".format(h, m)
@@ -1,8 +1,5 @@
package com.utun.chat.ui.screens
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -12,10 +9,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.utun.chat.data.Channel
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import com.utun.chat.ui.components.InputBar
import com.utun.chat.ui.components.MessageBubble
import com.utun.chat.viewmodel.ChatViewModel
@@ -34,12 +31,17 @@ fun ChatScreen(
var text by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val isRecording by viewModel.isRecording.collectAsState()
val ctx = LocalContext.current
val isRecordingLocked by viewModel.isRecordingLocked.collectAsState()
val recordingDurationMs by viewModel.recordingDurationMs.collectAsState()
var recordingLevel by remember { mutableStateOf(0f) }
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let {
val path = it.path ?: return@let
viewModel.sendAttachment(path)
/* Poll peak level during recording */
LaunchedEffect(isRecording) {
if (isRecording) {
while (true) {
recordingLevel = NativeLib.voiceGetPeakLevel()
delay(80)
}
}
}
@@ -74,13 +76,20 @@ fun ChatScreen(
},
enabled = true,
isRecording = isRecording,
recordingDurationMs = 0,
isRecordingLocked = isRecordingLocked,
recordingDurationMs = recordingDurationMs,
recordingLevel = recordingLevel,
onPttPressed = { viewModel.startVoiceRecording() },
onPttReleased = {
val dur = viewModel.stopVoiceRecording()
/* duration handled internally */
if (!viewModel.isRecordingLocked.value) viewModel.sendVoiceMessage()
},
onAttachClicked = { filePicker.launch("*/*") },
onPttLock = { viewModel.lockVoiceRecording() },
onVoiceSend = { viewModel.sendVoiceMessage() },
onVoiceCancel = {
viewModel.cancelVoiceRecording()
recordingLevel = 0f
},
onAttachClicked = {},
modifier = Modifier.navigationBarsPadding()
)
}
@@ -41,6 +41,14 @@ class ChatViewModel : ViewModel() {
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording
private val _isRecordingLocked = MutableStateFlow(false)
val isRecordingLocked: StateFlow<Boolean> = _isRecordingLocked
private val _recordingDurationMs = MutableStateFlow(0)
val recordingDurationMs: StateFlow<Int> = _recordingDurationMs
private var recordingStartTimeMs = 0L
init {
viewModelScope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
@@ -67,6 +75,14 @@ class ChatViewModel : ViewModel() {
_isStuck.value = !NativeLib.isResponsive()
}
}
viewModelScope.launch {
while (isActive) {
if (_isRecording.value) {
_recordingDurationMs.value = (System.currentTimeMillis() - recordingStartTimeMs).toInt()
}
delay(80)
}
}
}
private fun handleEvent(type: Int, data: ByteArray?) {
@@ -180,27 +196,49 @@ class ChatViewModel : ViewModel() {
fun startVoiceRecording() {
val ch = _currentChannel.value ?: return
_isRecording.value = true
_isRecordingLocked.value = false
recordingStartTimeMs = System.currentTimeMillis()
_recordingDurationMs.value = 0
audioRecorder.startRecording(ch.id)
LogManager.addLog("INFO", "VM", "voice recording started ch=${ch.id}")
}
fun lockVoiceRecording() {
_isRecordingLocked.value = true
LogManager.addLog("DEBUG", "VM", "voice recording locked")
}
fun stopVoiceRecording(): Int {
if (!_isRecording.value) return 0
val duration = audioRecorder.stopRecording()
_isRecording.value = false
_isRecordingLocked.value = false
_recordingDurationMs.value = 0
val ch = _currentChannel.value
if (ch != null) {
if (ch != null && duration >= 500) {
viewModelScope.launch {
delay(500)
refreshMessages(ch.id)
refreshChannels()
}
}
LogManager.addLog("INFO", "VM", "voice recording stopped dur=${duration}ms")
return duration
}
fun cancelVoiceRecording() {
audioRecorder.cancelRecording()
_isRecording.value = false
_isRecordingLocked.value = false
_recordingDurationMs.value = 0
LogManager.addLog("INFO", "VM", "voice recording cancelled")
}
fun sendVoiceMessage() {
val duration = stopVoiceRecording()
if (duration < 500) {
LogManager.addLog("INFO", "VM", "voice too short (${duration}ms), discarded")
}
}
fun sendAttachment(filePath: String) {