android: voice messages recording + playback, file attachments

- C99 port of AudioCompressor (libutun_lite/audio_compressor.h/c)
- Voice recorder: PCM buffer + compressor + Opus encode + block splitting + uasync send
- Attachment sender: file splitting into blocks + uasync send
- Bridge API + JNI: voice start/feed/stop/cancel, attachment send, Opus decode for playback
- Kotlin: AudioRecorderManager (AudioRecord + JNI feed), AudioPlayer (AudioTrack + JNI decode)
- UI: PTT button with press-to-talk, attach button with file picker, recording overlay
- Message display: voice messages (play button), file attachments (icon + name)
- Settings: Opus preset selector (Low/Standard/High), compressor toggle
- AndroidManifest: RECORD_AUDIO, storage permissions
This commit is contained in:
evgeny committed 2026-07-28 23:15:59 +03:00
1 parent e751e6ee69
commit 5b6979da27
18 files changed
+1725 -18

No files matched your search

@@ -67,6 +67,7 @@ class MainActivity : ComponentActivity() {
channel = channel,
messages = messages,
connected = connected,
viewModel = vm,
onSendMessage = { vm.sendMessage(it) },
onBack = { screen = Screen.ChannelList }
)
@@ -0,0 +1,189 @@
package com.utun.chat.data
import android.Manifest
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
class AudioRecorderManager {
companion object {
const val SAMPLE_RATE = 48000
const val CHANNELS = 1
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000
const val MIN_DURATION_MS = 500
}
private var audioRecord: AudioRecord? = null
private var recordingThread: Thread? = null
@Volatile private var isRecording = false
fun hasPermission(): Boolean {
val ctx = ChatApplication.instance
return ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
}
fun startRecording(channelId: String): Boolean {
if (isRecording) return false
if (!hasPermission()) return false
val ok = NativeLib.voiceStart(channelId)
if (!ok) {
LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed")
return false
}
val bufferSize = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
).coerceAtLeast(FRAME_SAMPLES * 2)
audioRecord = try {
AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize * 2
)
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioRecorder", "AudioRecord create failed: ${e.message}")
NativeLib.voiceCancel()
return false
}
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "AudioRecorder", "AudioRecord not initialized")
audioRecord?.release()
audioRecord = null
NativeLib.voiceCancel()
return false
}
isRecording = true
LogManager.addLog("INFO", "AudioRecorder", "started ch=$channelId bufferSize=$bufferSize")
audioRecord?.startRecording()
recordingThread = Thread {
val buffer = ShortArray(FRAME_SAMPLES)
var totalRead = 0L
while (isRecording && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
val read = audioRecord?.read(buffer, 0, FRAME_SAMPLES) ?: break
if (read <= 0) break
NativeLib.voiceFeed(buffer.copyOf(read))
totalRead += read
}
LogManager.addLog("DEBUG", "AudioRecorder", "thread done totalSamples=$totalRead")
}.apply {
priority = Thread.MAX_PRIORITY
start()
}
return true
}
fun stopRecording(): Int {
if (!isRecording) return 0
isRecording = false
recordingThread?.join(1000)
recordingThread = null
audioRecord?.apply {
try { stop() } catch (_: Exception) {}
release()
}
audioRecord = null
val duration = NativeLib.voiceStop()
LogManager.addLog("INFO", "AudioRecorder", "stopped duration=${duration}ms")
return duration
}
fun cancelRecording() {
if (!isRecording) return
isRecording = false
recordingThread?.join(1000)
recordingThread = null
audioRecord?.apply {
try { stop() } catch (_: Exception) {}
release()
}
audioRecord = null
NativeLib.voiceCancel()
LogManager.addLog("INFO", "AudioRecorder", "cancelled")
}
fun isActive(): Boolean = isRecording
@Volatile private var playing = false
private var playThread: Thread? = null
fun playVoiceFile(filePath: String, onProgress: (Float) -> Unit = {}, onComplete: () -> Unit = {}) {
if (playing) stopPlayback()
playing = true
playThread = Thread {
try {
val info = IntArray(3)
val handle = NativeLib.voiceDecodeOpen(filePath, info)
if (handle == 0L) {
LogManager.addLog("ERROR", "AudioPlayer", "decode open failed: $filePath")
playing = false; return@Thread
}
val sampleRate = info[0]; val channels = info[1]; val totalSamples = info[2]
val bufSize = AudioTrack.getMinBufferSize(
sampleRate,
if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
val track = AudioTrack(
AudioManager.STREAM_MUSIC,
sampleRate,
if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufSize,
AudioTrack.MODE_STREAM
)
track.play()
val pcm = ShortArray(FRAME_SAMPLES * channels)
var readTotal = 0L
var read: Int
while (playing) {
read = NativeLib.voiceDecodeRead(handle, pcm)
if (read <= 0) break
track.write(pcm, 0, read)
readTotal += read
if (totalSamples > 0) onProgress(readTotal.toFloat() / totalSamples.toFloat())
}
track.stop()
track.release()
NativeLib.voiceDecodeClose(handle)
} catch (e: Exception) {
LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}")
}
playing = false
onComplete()
}.apply { start() }
}
fun stopPlayback() {
playing = false
playThread?.join(500)
playThread = null
}
}
@@ -6,7 +6,8 @@ import org.json.JSONObject
data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "",
val peersOnline: Int = 0)
data class Message(val id: Long, val author: String, val text: String, val ts: Long, val isOutgoing: Boolean = false)
data class Message(val id: Long, val author: String, val text: String, val ts: Long,
val isOutgoing: Boolean = false, val contentType: String = "text/plain")
class ChatRepository {
private val myNodeId: Long = NativeLib.getMyNodeId()
@@ -51,7 +52,8 @@ class ChatRepository {
author = obj.getString("author"),
text = obj.getString("text"),
ts = obj.getLong("ts"),
isOutgoing = obj.optBoolean("isOutgoing", false)
isOutgoing = obj.optBoolean("isOutgoing", false),
contentType = obj.optString("contentType", "text/plain")
))
}
} catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) }
@@ -59,6 +61,7 @@ class ChatRepository {
}
fun sendMessage(channelId: String, text: String) = NativeLib.sendMessage(channelId, text)
fun sendAttachment(channelId: String, filePath: String) = NativeLib.attachmentSend(channelId, filePath)
fun createChannel(name: String, channelId: String) = NativeLib.createChannel(name, channelId)
fun connectNode(address: String, port: Int, pubkeyHex: String) = NativeLib.connectNode(address, port, pubkeyHex)
fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) =
@@ -77,6 +77,25 @@ object NativeLib {
fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) }
fun regenerateKeys() { nativeRegenerateKeys() }
/* ── Voice recording ── */
fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId)
fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
fun voiceStop(): Int = nativeVoiceStop()
fun voiceCancel() { nativeVoiceCancel() }
fun voiceIsActive(): Boolean = nativeVoiceIsActive()
fun voiceSetPreset(preset: Int) { nativeVoiceSetPreset(preset) }
fun voiceGetPreset(): Int = nativeVoiceGetPreset()
fun voiceSetCompressor(enabled: Boolean) { nativeVoiceSetCompressor(enabled) }
fun voiceGetCompressor(): Boolean = nativeVoiceGetCompressor()
/* ── Attachment ── */
fun attachmentSend(channelId: String, filePath: String): Boolean = nativeAttachmentSend(channelId, filePath)
/* ── Voice playback ── */
fun voiceDecodeOpen(filePath: String, outInfo: IntArray): Long = nativeVoiceDecodeOpen(filePath, outInfo)
fun voiceDecodeRead(handle: Long, buf: ShortArray): Int = nativeVoiceDecodeRead(handle, buf)
fun voiceDecodeClose(handle: Long) { nativeVoiceDecodeClose(handle) }
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean
private external fun nativeStop()
@@ -98,4 +117,23 @@ object NativeLib {
private external fun nativeRestart(configText: String): Boolean
private external fun nativePing()
private external fun nativeIsResponsive(): Boolean
/* ── Voice recording JNI ── */
private external fun nativeVoiceStart(channelId: String): Boolean
private external fun nativeVoiceFeed(samples: ShortArray): Boolean
private external fun nativeVoiceStop(): Int
private external fun nativeVoiceCancel()
private external fun nativeVoiceIsActive(): Boolean
private external fun nativeVoiceSetPreset(preset: Int)
private external fun nativeVoiceGetPreset(): Int
private external fun nativeVoiceSetCompressor(enabled: Boolean)
private external fun nativeVoiceGetCompressor(): Boolean
/* ── Attachment JNI ── */
private external fun nativeAttachmentSend(channelId: String, filePath: String): Boolean
/* ── Voice playback JNI ── */
private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long
private external fun nativeVoiceDecodeRead(handle: Long, buf: ShortArray): Int
private external fun nativeVoiceDecodeClose(handle: Long)
}
@@ -1,19 +1,28 @@
package com.utun.chat.ui.components
import android.Manifest
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.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.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 kotlinx.coroutines.delay
@Composable
fun MessageBubble(message: Message) {
@@ -22,6 +31,9 @@ fun MessageBubble(message: Message) {
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
val minBubbleWidth = (LocalConfiguration.current.screenWidthDp / 3).dp
val isVoice = message.contentType == "audio/opus"
val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain"
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = alignment
@@ -35,7 +47,23 @@ fun MessageBubble(message: Message) {
) {
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
Spacer(Modifier.height(2.dp))
Text(message.text, fontSize = 17.sp, color = textColor)
when {
isVoice -> {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("\u25B6", fontSize = 20.sp, color = textColor)
Spacer(Modifier.width(8.dp))
Text("Voice message", fontSize = 15.sp, color = textColor)
}
}
isMedia -> {
Text("\uD83D\uDCC4 ${message.text}", fontSize = 15.sp, color = textColor)
}
else -> {
Text(message.text, fontSize = 17.sp, color = textColor)
}
}
Spacer(Modifier.height(2.dp))
Text(
formatTime(message.ts),
@@ -53,27 +81,107 @@ fun InputBar(
onTextChange: (String) -> Unit,
onSend: () -> Unit,
enabled: Boolean = true,
isRecording: Boolean = false,
recordingDurationMs: Int = 0,
onPttPressed: () -> Unit = {},
onPttReleased: () -> Unit = {},
onAttachClicked: () -> Unit = {},
modifier: Modifier = Modifier
) {
val ctx = LocalContext.current
var hasMicPerm by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
val permLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasMicPerm = granted
}
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { onAttachClicked() }
}
Row(
modifier = modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = text,
onValueChange = onTextChange,
enabled = enabled,
modifier = Modifier.weight(1f),
placeholder = { Text("Message...") },
maxLines = 4
)
Spacer(Modifier.width(8.dp))
/* Attach button */
IconButton(onClick = { filePicker.launch("*/*") }, enabled = enabled) {
Text("\uD83D\uDCCE", fontSize = 20.sp)
}
if (isRecording) {
/* Recording overlay */
Surface(
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(8.dp),
color = Color(0xFFE53935)
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "\uD83D\uDD34 ${formatDuration(recordingDurationMs)}",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
/* 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,
enabled = enabled,
modifier = Modifier.weight(1f),
placeholder = { Text("Message...") },
maxLines = 4
)
}
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 = {
onPttPressed()
tryAwaitRelease()
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
return "%d:%02d".format(secs / 60, secs % 60)
}
private fun formatTime(ts: Long): String {
if (ts == 0L) return ""
val s = ts / 1_000_000_000 // nanoseconds -> seconds
@@ -1,5 +1,8 @@
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
@@ -9,11 +12,14 @@ 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.ui.components.InputBar
import com.utun.chat.ui.components.MessageBubble
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -21,11 +27,21 @@ fun ChatScreen(
channel: Channel,
messages: List<Message>,
connected: Boolean,
viewModel: ChatViewModel,
onSendMessage: (String) -> Unit,
onBack: () -> Unit
) {
var text by remember { mutableStateOf("") }
val listState = rememberLazyListState()
val isRecording by viewModel.isRecording.collectAsState()
val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let {
val ctx = LocalContext.current
val path = it.path ?: return@let
viewModel.sendAttachment(path)
}
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
@@ -57,6 +73,14 @@ fun ChatScreen(
text = ""
},
enabled = true,
isRecording = isRecording,
recordingDurationMs = 0,
onPttPressed = { viewModel.startVoiceRecording() },
onPttReleased = {
val dur = viewModel.stopVoiceRecording()
/* duration handled internally */
},
onAttachClicked = { filePicker.launch("*/*") },
modifier = Modifier.navigationBarsPadding()
)
}
@@ -294,6 +294,37 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
// ── Voice Messages ──
Text("Voice Messages", style = MaterialTheme.typography.titleMedium)
val voicePreset by remember { mutableStateOf(vm.getVoicePreset()) }
var presetExpanded by remember { mutableStateOf(false) }
val presets = listOf("Low (16 kbps)", "Standard (32 kbps)", "High (64 kbps)")
ExposedDropdownMenuBox(expanded = presetExpanded, onExpandedChange = { presetExpanded = it }) {
OutlinedTextField(
value = presets[voicePreset], onValueChange = {},
readOnly = true, singleLine = true, label = { Text("Opus Preset") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(presetExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp)
)
ExposedDropdownMenu(presetExpanded, onDismissRequest = { presetExpanded = false }) {
presets.forEachIndexed { i, name ->
DropdownMenuItem(text = { Text(name) }, onClick = {
vm.setVoicePreset(i); presetExpanded = false
})
}
}
}
val compressorEnabled = remember { mutableStateOf(vm.getVoiceCompressor()) }
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Text("Compressor", modifier = Modifier.weight(1f))
Switch(
checked = compressorEnabled.value,
onCheckedChange = { v -> compressorEnabled.value = v; vm.setVoiceCompressor(v) }
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
// ── Debug ──
Text("Debug", style = MaterialTheme.typography.titleMedium)
DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug)
@@ -36,6 +36,11 @@ class ChatViewModel : ViewModel() {
private val _isStuck = MutableStateFlow(false)
val isStuck: StateFlow<Boolean> = _isStuck
val audioRecorder = AudioRecorderManager()
private val _isRecording = MutableStateFlow(false)
val isRecording: StateFlow<Boolean> = _isRecording
init {
viewModelScope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
@@ -172,6 +177,48 @@ class ChatViewModel : ViewModel() {
r.joinChannel(channelId, nodeId, pubkey, addrs, addrCount)
}
fun startVoiceRecording() {
val ch = _currentChannel.value ?: return
_isRecording.value = true
audioRecorder.startRecording(ch.id)
}
fun stopVoiceRecording(): Int {
if (!_isRecording.value) return 0
val duration = audioRecorder.stopRecording()
_isRecording.value = false
val ch = _currentChannel.value
if (ch != null) {
viewModelScope.launch {
delay(500)
refreshMessages(ch.id)
refreshChannels()
}
}
return duration
}
fun cancelVoiceRecording() {
audioRecorder.cancelRecording()
_isRecording.value = false
}
fun sendAttachment(filePath: String) {
val r = repo ?: return
val ch = _currentChannel.value ?: return
r.sendAttachment(ch.id, filePath)
viewModelScope.launch {
delay(500)
refreshMessages(ch.id)
refreshChannels()
}
}
fun getVoicePreset(): Int = NativeLib.voiceGetPreset()
fun setVoicePreset(preset: Int) { NativeLib.voiceSetPreset(preset) }
fun getVoiceCompressor(): Boolean = NativeLib.voiceGetCompressor()
fun setVoiceCompressor(enabled: Boolean) { NativeLib.voiceSetCompressor(enabled) }
fun clearState() {
repo?.close()
repo = null