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. 122
      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 voiceGetPreset(): Int = nativeVoiceGetPreset()
fun voiceSetCompressor(enabled: Boolean) { nativeVoiceSetCompressor(enabled) } fun voiceSetCompressor(enabled: Boolean) { nativeVoiceSetCompressor(enabled) }
fun voiceGetCompressor(): Boolean = nativeVoiceGetCompressor() fun voiceGetCompressor(): Boolean = nativeVoiceGetCompressor()
fun voiceGetPeakLevel(): Float = nativeVoiceGetPeakLevel()
/* ── Attachment ── */ /* ── Attachment ── */
fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath) fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath)
@ -128,6 +129,7 @@ object NativeLib {
private external fun nativeVoiceGetPreset(): Int private external fun nativeVoiceGetPreset(): Int
private external fun nativeVoiceSetCompressor(enabled: Boolean) private external fun nativeVoiceSetCompressor(enabled: Boolean)
private external fun nativeVoiceGetCompressor(): Boolean private external fun nativeVoiceGetCompressor(): Boolean
private external fun nativeVoiceGetPeakLevel(): Float
/* ── Attachment JNI ── */ /* ── Attachment JNI ── */
private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean

122
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.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background 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.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* 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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.utun.chat.data.Message import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@Composable @Composable
@ -75,6 +77,8 @@ fun MessageBubble(message: Message) {
} }
} }
private val LOCK_SWIPE_THRESHOLD_DP = 80f
@Composable @Composable
fun InputBar( fun InputBar(
text: String, text: String,
@ -82,13 +86,20 @@ fun InputBar(
onSend: () -> Unit, onSend: () -> Unit,
enabled: Boolean = true, enabled: Boolean = true,
isRecording: Boolean = false, isRecording: Boolean = false,
isRecordingLocked: Boolean = false,
recordingDurationMs: Int = 0, recordingDurationMs: Int = 0,
recordingLevel: Float = 0f,
onPttPressed: () -> Unit = {}, onPttPressed: () -> Unit = {},
onPttReleased: () -> Unit = {}, onPttReleased: () -> Unit = {},
onPttLock: () -> Unit = {},
onVoiceSend: () -> Unit = {},
onVoiceCancel: () -> Unit = {},
onAttachClicked: () -> Unit = {}, onAttachClicked: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val ctx = LocalContext.current val ctx = LocalContext.current
val density = LocalDensity.current
val lockThresholdPx = with(density) { LOCK_SWIPE_THRESHOLD_DP.dp.toPx() }
var hasMicPerm by remember { var hasMicPerm by remember {
mutableStateOf( mutableStateOf(
@ -100,21 +111,23 @@ fun InputBar(
hasMicPerm = granted hasMicPerm = granted
} }
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { _ -> }
uri?.let { onAttachClicked() }
}
Row( Row(
modifier = modifier.fillMaxWidth().padding(8.dp), modifier = modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
/* Attach button */ /* Attach button */
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) { if (!isRecording) {
Text("\uD83D\uDCCE", fontSize = 20.sp) 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) { if (isRecording) {
/* Recording overlay */
Surface( Surface(
modifier = Modifier.weight(1f).height(48.dp), modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
@ -122,23 +135,25 @@ fun InputBar(
) { ) {
Row( Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Text(
text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}", text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}",
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, 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 { } else {
/* Text input */
OutlinedTextField( OutlinedTextField(
value = text, value = text,
onValueChange = onTextChange, onValueChange = onTextChange,
@ -151,28 +166,69 @@ fun InputBar(
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
/* PTT button — press to talk */ if (isRecording && isRecordingLocked) {
IconButton( /* Locked: cancel + send voice */
onClick = { IconButton(onClick = onVoiceCancel) {
if (!hasMicPerm) permLauncher.launch(Manifest.permission.RECORD_AUDIO) Text("\u2715", fontSize = 20.sp, color = Color(0xFFE53935))
}, }
enabled = enabled && hasMicPerm, IconButton(onClick = onVoiceSend) {
modifier = Modifier.pointerInput(Unit) { Text("\u25B6", fontSize = 20.sp, color = Color(0xFF4CAF50))
detectTapGestures( }
onPress = { } 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() 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() onPttReleased()
} }
) }
) {
Text("\uD83C\uDFA4", fontSize = 20.sp)
} }
) {
Text("\uD83C\uDFA4", fontSize = 20.sp)
}
/* Send button */ Button(onClick = onSend, enabled = enabled && text.isNotBlank()) {
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) { Text("Send")
Text("Send") }
} }
} }
} }
@ -184,7 +240,7 @@ private fun formatDuration(ms: Int): String {
private fun formatTime(ts: Long): String { private fun formatTime(ts: Long): String {
if (ts == 0L) return "" 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 h = (s / 3600) % 24
val m = (s / 60) % 60 val m = (s / 60) % 60
return "%02d:%02d".format(h, m) 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 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.utun.chat.data.Channel import com.utun.chat.data.Channel
import com.utun.chat.data.Message 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.InputBar
import com.utun.chat.ui.components.MessageBubble import com.utun.chat.ui.components.MessageBubble
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
@ -34,12 +31,17 @@ fun ChatScreen(
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val isRecording by viewModel.isRecording.collectAsState() 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 -> /* Poll peak level during recording */
uri?.let { LaunchedEffect(isRecording) {
val path = it.path ?: return@let if (isRecording) {
viewModel.sendAttachment(path) while (true) {
recordingLevel = NativeLib.voiceGetPeakLevel()
delay(80)
}
} }
} }
@ -74,13 +76,20 @@ fun ChatScreen(
}, },
enabled = true, enabled = true,
isRecording = isRecording, isRecording = isRecording,
recordingDurationMs = 0, isRecordingLocked = isRecordingLocked,
recordingDurationMs = recordingDurationMs,
recordingLevel = recordingLevel,
onPttPressed = { viewModel.startVoiceRecording() }, onPttPressed = { viewModel.startVoiceRecording() },
onPttReleased = { onPttReleased = {
val dur = viewModel.stopVoiceRecording() if (!viewModel.isRecordingLocked.value) viewModel.sendVoiceMessage()
/* duration handled internally */ },
onPttLock = { viewModel.lockVoiceRecording() },
onVoiceSend = { viewModel.sendVoiceMessage() },
onVoiceCancel = {
viewModel.cancelVoiceRecording()
recordingLevel = 0f
}, },
onAttachClicked = { filePicker.launch("*/*") }, onAttachClicked = {},
modifier = Modifier.navigationBarsPadding() 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) private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording 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 { init {
viewModelScope.launch { viewModelScope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) } AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
@ -67,6 +75,14 @@ class ChatViewModel : ViewModel() {
_isStuck.value = !NativeLib.isResponsive() _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?) { private fun handleEvent(type: Int, data: ByteArray?) {
@ -180,27 +196,49 @@ class ChatViewModel : ViewModel() {
fun startVoiceRecording() { fun startVoiceRecording() {
val ch = _currentChannel.value ?: return val ch = _currentChannel.value ?: return
_isRecording.value = true _isRecording.value = true
_isRecordingLocked.value = false
recordingStartTimeMs = System.currentTimeMillis()
_recordingDurationMs.value = 0
audioRecorder.startRecording(ch.id) 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 { fun stopVoiceRecording(): Int {
if (!_isRecording.value) return 0 if (!_isRecording.value) return 0
val duration = audioRecorder.stopRecording() val duration = audioRecorder.stopRecording()
_isRecording.value = false _isRecording.value = false
_isRecordingLocked.value = false
_recordingDurationMs.value = 0
val ch = _currentChannel.value val ch = _currentChannel.value
if (ch != null) { if (ch != null && duration >= 500) {
viewModelScope.launch { viewModelScope.launch {
delay(500) delay(500)
refreshMessages(ch.id) refreshMessages(ch.id)
refreshChannels() refreshChannels()
} }
} }
LogManager.addLog("INFO", "VM", "voice recording stopped dur=${duration}ms")
return duration return duration
} }
fun cancelVoiceRecording() { fun cancelVoiceRecording() {
audioRecorder.cancelRecording() audioRecorder.cancelRecording()
_isRecording.value = false _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) { 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(); 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) { 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); 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; 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 ── */ /* ── Attachment JNI ── */
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeAttachmentSend( 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); int utun_bridge_voice_get_preset(void);
void utun_bridge_voice_set_compressor(int enabled); void utun_bridge_voice_set_compressor(int enabled);
int utun_bridge_voice_get_compressor(void); int utun_bridge_voice_get_compressor(void);
float utun_bridge_voice_get_peak_level(void);
/* ── Attachment ── */ /* ── Attachment ── */

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

@ -10,6 +10,7 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <math.h>
#include <time.h> #include <time.h>
#include <pthread.h> #include <pthread.h>
#include <sys/stat.h> #include <sys/stat.h>
@ -44,6 +45,8 @@ struct voice_recorder {
char db_path[512]; char db_path[512];
int preset; int preset;
float peak_level;
int64_t start_time_ms; 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); pthread_mutex_lock(&g_rec->mtx);
if (!g_rec->active) { pthread_mutex_unlock(&g_rec->mtx); pthread_mutex_unlock(&g_init_mtx); return -1; } 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) { if (g_rec->compressor && g_rec->compressor_enabled) {
audio_compressor_push(g_rec->compressor, samples, (size_t)count); audio_compressor_push(g_rec->compressor, samples, (size_t)count);
} else { } else {
@ -446,3 +458,11 @@ int voice_recorder_is_compressor_enabled(void) {
pthread_mutex_unlock(&g_init_mtx); pthread_mutex_unlock(&g_init_mtx);
return e; 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); int voice_recorder_get_preset(void);
void voice_recorder_set_compressor_enabled(int enabled); void voice_recorder_set_compressor_enabled(int enabled);
int voice_recorder_is_compressor_enabled(void); int voice_recorder_is_compressor_enabled(void);
float voice_recorder_get_peak_level(void);
#ifdef __cplusplus #ifdef __cplusplus
} }

Loading…
Cancel
Save