Browse Source

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
topo_upd
evgeny 2 months ago
parent
commit
9b6dd6476c
  1. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  2. 100
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  3. 35
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  4. 40
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  5. 10
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  6. 1
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  7. 20
      tools/chatgui-android/libutun_lite/voice_recorder.c
  8. 1
      tools/chatgui-android/libutun_lite/voice_recorder.h

2
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

100
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 */
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,31 +166,72 @@ fun InputBar(
Spacer(Modifier.width(4.dp))
/* PTT button — press to talk */
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)
if (!hasMicPerm) { permLauncher.launch(Manifest.permission.RECORD_AUDIO); return@IconButton }
},
enabled = enabled && hasMicPerm,
enabled = true,
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
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)
}
/* Send button */
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) {
Text("Send")
}
}
}
}
private fun formatDuration(ms: Int): String {
val secs = ms / 1000
@ -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)

35
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()
)
}

40
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<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) {

10
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(

1
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 ── */

20
tools/chatgui-android/libutun_lite/voice_recorder.c

@ -10,6 +10,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <time.h>
#include <pthread.h>
#include <sys/stat.h>
@ -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;
}

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

Loading…
Cancel
Save