From 9b6dd6476ce4da2dbe8496c309a80487262d04ad Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 28 Jul 2026 23:28:19 +0300 Subject: [PATCH] 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 --- .../main/java/com/utun/chat/data/NativeLib.kt | 2 + .../utun/chat/ui/components/MessageBubble.kt | 122 +++++++++++++----- .../com/utun/chat/ui/screens/ChatScreen.kt | 35 +++-- .../com/utun/chat/viewmodel/ChatViewModel.kt | 40 +++++- .../jni_bridge/android_jni_bridge.c | 10 ++ .../jni_bridge/android_jni_bridge.h | 1 + .../libutun_lite/voice_recorder.c | 20 +++ .../libutun_lite/voice_recorder.h | 1 + 8 files changed, 184 insertions(+), 47 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 8cb88e5f..b22258bb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -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 diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index 7d3587c9..8c174b21 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -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) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 1934db8c..2bbcc458 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -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() + }, + onPttLock = { viewModel.lockVoiceRecording() }, + onVoiceSend = { viewModel.sendVoiceMessage() }, + onVoiceCancel = { + viewModel.cancelVoiceRecording() + recordingLevel = 0f }, - onAttachClicked = { filePicker.launch("*/*") }, + onAttachClicked = {}, modifier = Modifier.navigationBarsPadding() ) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index a1e31614..43e87b33 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -41,6 +41,14 @@ class ChatViewModel : ViewModel() { private val _isRecording = MutableStateFlow(false) val isRecording: StateFlow = _isRecording + private val _isRecordingLocked = MutableStateFlow(false) + val isRecordingLocked: StateFlow = _isRecordingLocked + + private val _recordingDurationMs = MutableStateFlow(0) + val recordingDurationMs: StateFlow = _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) { diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 675cb1af..118d6cef 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -467,6 +467,10 @@ int utun_bridge_voice_get_compressor(void) { return voice_recorder_is_compressor_enabled(); } +float utun_bridge_voice_get_peak_level(void) { + return voice_recorder_get_peak_level(); +} + int utun_bridge_attachment_send(const char* channel_id, const char* file_path, const char* db_path) { return attachment_send(channel_id, file_path, db_path); } @@ -986,6 +990,12 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceGetCompr return utun_bridge_voice_get_compressor() ? JNI_TRUE : JNI_FALSE; } +JNIEXPORT jfloat JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceGetPeakLevel( + JNIEnv* env, jobject thiz) { + (void)env; (void)thiz; + return (jfloat)utun_bridge_voice_get_peak_level(); +} + /* ── Attachment JNI ── */ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeAttachmentSend( diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index ece716ed..9800bb89 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -85,6 +85,7 @@ void utun_bridge_voice_set_preset(int preset); int utun_bridge_voice_get_preset(void); void utun_bridge_voice_set_compressor(int enabled); int utun_bridge_voice_get_compressor(void); +float utun_bridge_voice_get_peak_level(void); /* ── Attachment ── */ diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.c b/tools/chatgui-android/libutun_lite/voice_recorder.c index 70987701..9110322b 100644 --- a/tools/chatgui-android/libutun_lite/voice_recorder.c +++ b/tools/chatgui-android/libutun_lite/voice_recorder.c @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,8 @@ struct voice_recorder { char db_path[512]; int preset; + float peak_level; + int64_t start_time_ms; }; @@ -146,6 +149,15 @@ int voice_recorder_feed(const int16_t* samples, int count) { pthread_mutex_lock(&g_rec->mtx); if (!g_rec->active) { pthread_mutex_unlock(&g_rec->mtx); pthread_mutex_unlock(&g_init_mtx); return -1; } + /* compute peak level for UI */ + { float sum_sq = 0.0f; float peak = 0.0f; + for (int i = 0; i < count; i++) { + float v = (float)samples[i] / 32768.0f; + sum_sq += v * v; float av = fabsf(v); if (av > peak) peak = av; + } + g_rec->peak_level = (sqrtf(sum_sq / (float)count) * 2.0f + peak) / 2.0f; + } + if (g_rec->compressor && g_rec->compressor_enabled) { audio_compressor_push(g_rec->compressor, samples, (size_t)count); } else { @@ -446,3 +458,11 @@ int voice_recorder_is_compressor_enabled(void) { pthread_mutex_unlock(&g_init_mtx); return e; } + +float voice_recorder_get_peak_level(void) { + float v = 0.0f; + pthread_mutex_lock(&g_init_mtx); + if (g_rec) { pthread_mutex_lock(&g_rec->mtx); v = g_rec->peak_level; pthread_mutex_unlock(&g_rec->mtx); } + pthread_mutex_unlock(&g_init_mtx); + return v; +} diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.h b/tools/chatgui-android/libutun_lite/voice_recorder.h index 6404d3cf..9618aeed 100644 --- a/tools/chatgui-android/libutun_lite/voice_recorder.h +++ b/tools/chatgui-android/libutun_lite/voice_recorder.h @@ -23,6 +23,7 @@ void voice_recorder_set_preset(int preset); int voice_recorder_get_preset(void); void voice_recorder_set_compressor_enabled(int enabled); int voice_recorder_is_compressor_enabled(void); +float voice_recorder_get_peak_level(void); #ifdef __cplusplus }