From c863b809178fb566bece78a0b3a627932a4f8584 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 1 Oct 2026 19:09:07 +0300 Subject: [PATCH] Validate Android voice streams and keep playback cancellation off UI waits --- .../utun/chat/data/AudioRecorderManager.kt | 43 +++++++++++-------- .../jni_bridge/android_jni_bridge.c | 37 ++++++++++------ 2 files changed, 50 insertions(+), 30 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt index 9d55ce28..c9dbe786 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt @@ -196,24 +196,27 @@ class AudioRecorderManager { fun playFromPosition(filePath: String, startMs: Int, onProgress: ((Float, Int) -> Unit)? = null, onComplete: () -> Unit = {}) { stopPlayback() playing = true + val myGen = playGeneration playThread = Thread { - val myGen = playGeneration + var handle = 0L + var track: AudioTrack? = null try { val info = IntArray(3) - val handle = NativeLib.voiceDecodeOpen(filePath, info) + handle = NativeLib.voiceDecodeOpen(filePath, info) if (handle == 0L) { LogManager.addLog("ERROR", "AudioPlayer", "decode open failed: $filePath") - playing = false; onComplete(); return@Thread + return@Thread } val sampleRate = info[0]; val channels = info[1]; val totalSamples = info[2] /* skip to startMs by decoding and discarding frames */ - val frameSamples = FRAME_SAMPLES * channels + val frameSamples = sampleRate / 50 * channels val skipFrames = startMs / FRAME_MS val skipBuf = ShortArray(frameSamples) var skippedSamples = 0L for (f in 0 until skipFrames) { + if (myGen != playGeneration) return@Thread val dec = NativeLib.voiceDecodeRead(handle, skipBuf) if (dec <= 0) break skippedSamples += dec @@ -223,43 +226,49 @@ class AudioRecorderManager { if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT) - val track = AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, + val output = 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() + track = output + output.play() val pcm = ShortArray(frameSamples) var readTotal = 0L var read: Int - while (playing) { + while (playing && myGen == playGeneration) { read = NativeLib.voiceDecodeRead(handle, pcm) if (read <= 0) break - track.write(pcm, 0, read) + if (myGen != playGeneration) break + output.write(pcm, 0, read) readTotal += read - val total = (skippedSamples + readTotal).toFloat() / (totalSamples * channels).coerceAtLeast(1).toFloat() + val total = (skippedSamples + readTotal).toFloat() / totalSamples.coerceAtLeast(1).toFloat() val eMs = startMs + (readTotal * FRAME_MS / frameSamples).toInt() onProgress?.invoke(total, eMs) } val drainMs = (bufSize * 1000L) / (sampleRate.toLong() * channels * 2) + 100 - try { Thread.sleep(drainMs) } catch (_: InterruptedException) {} - track.stop(); track.release() - NativeLib.voiceDecodeClose(handle) + if (myGen == playGeneration) Thread.sleep(drainMs) + } catch (_: InterruptedException) { + LogManager.addLog("DEBUG", "AudioPlayer", "playback cancelled") } catch (e: Exception) { LogManager.addLog("ERROR", "AudioPlayer", "play error: ${e.message}") - } - if (playGeneration == myGen) { - playing = false; onComplete() + } finally { + track?.apply { + try { stop() } catch (e: Exception) { LogManager.addLog("WARN", "AudioPlayer", "stop failed: ${e.message}") } + release() + } + if (handle != 0L) NativeLib.voiceDecodeClose(handle) + if (playGeneration == myGen) { playing = false; onComplete() } } }.apply { start() } } fun pausePlayback() { playGeneration++; playing = false - playThread?.join(500); playThread = null + playThread?.interrupt(); playThread = null } fun stopPlayback() { playGeneration++; playing = false - playThread?.join(500); playThread = null + playThread?.interrupt(); playThread = null } } diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index fb0d3eed..468b0cc8 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -38,6 +38,7 @@ #include #include #include +#include #include #ifdef __ANDROID__ @@ -1371,15 +1372,18 @@ void* utun_bridge_voice_decode_open(const char* file_path, uint32_t magic = 0; uint32_t sr = 0; uint16_t ch = 0, fs = 0; if (fread(&magic, 4, 1, f) != 1 || magic != OPUS_MAGIC_D) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "voice_decode: bad magic"); fclose(f); return NULL; } - if (fread(&sr, 4, 1, f) != 1) { fclose(f); return NULL; } - if (fread(&ch, 2, 1, f) != 1) { fclose(f); return NULL; } - if (fread(&fs, 2, 1, f) != 1) { fclose(f); return NULL; } + if (fread(&sr, 4, 1, f) != 1 || fread(&ch, 2, 1, f) != 1 || fread(&fs, 2, 1, f) != 1 || + (sr != 8000 && sr != 12000 && sr != 16000 && sr != 24000 && sr != 48000) || + (ch != 1 && ch != 2) || fs != sr / 50) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice decode: invalid header rate=%u channels=%u frame=%u", sr, ch, fs); + fclose(f); return NULL; + } opus_codec_decoder_t* dec = opus_codec_decoder_create((int)sr, (int)ch); - if (!dec) { fclose(f); return NULL; } + if (!dec) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice decode: decoder allocation failed"); fclose(f); return NULL; } struct voice_decode_handle* h = u_calloc(1, sizeof(*h)); - if (!h) { opus_codec_decoder_destroy(dec); fclose(f); return NULL; } + if (!h) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice decode: handle allocation failed"); opus_codec_decoder_destroy(dec); fclose(f); return NULL; } h->dec = dec; h->file = f; h->sample_rate = (int)sr; h->channels = (int)ch; h->frame_samples = (int)fs; @@ -1388,12 +1392,16 @@ void* utun_bridge_voice_decode_open(const char* file_path, long pos = ftell(f); h->total_samples = 0; for (;;) { - uint16_t plen; - if (fread(&plen, 2, 1, f) != 1 || plen == 0) break; - fseek(f, plen, SEEK_CUR); + uint16_t plen = 0; + if (fread(&plen, 2, 1, f) != 1) goto invalid_stream; + if (!plen) { + if (!h->total_samples || fgetc(f) != EOF || ferror(f)) goto invalid_stream; + break; + } + if (plen > 4000 || h->total_samples > INT_MAX - (int)fs * ch || fseek(f, plen, SEEK_CUR)) goto invalid_stream; h->total_samples += (int)fs * (int)ch; } - fseek(f, pos, SEEK_SET); + if (fseek(f, pos, SEEK_SET)) goto invalid_stream; *out_sample_rate = h->sample_rate; *out_channels = h->channels; @@ -1402,6 +1410,9 @@ void* utun_bridge_voice_decode_open(const char* file_path, DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_decode_open: %s rate=%d ch=%d fs=%d total=%d", file_path, h->sample_rate, h->channels, h->frame_samples, h->total_samples); return h; +invalid_stream: + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice decode: truncated/invalid stream path=%s samples=%d", file_path, h->total_samples); + utun_bridge_voice_decode_close(h); return NULL; } int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples) { @@ -1414,12 +1425,12 @@ int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples) { while (written + h->frame_samples * h->channels <= max_samples) { uint16_t plen; - if (fread(&plen, 2, 1, h->file) != 1 || plen == 0) break; - if (plen > sizeof(packet)) { fseek(h->file, plen, SEEK_CUR); break; } - if (fread(packet, 1, plen, h->file) != plen) break; + if (fread(&plen, 2, 1, h->file) != 1 || plen == 0 || plen > 4000 || fread(packet, 1, plen, h->file) != plen) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice decode: stream changed after validation"); return -1; + } int decoded = opus_codec_decode(h->dec, packet, plen, buf + written, h->frame_samples); - if (decoded < 0) { DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "voice_decode_read: error %d", decoded); break; } + if (decoded != h->frame_samples) { DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "voice decode: invalid packet result=%d", decoded); return -1; } written += decoded * h->channels; } h->pcm_consumed += written;