Android: fix upload lifecycle and JNI UTF-8 abort (M3a hardening)

- upload ran as a detached coroutine while cleanup() stopped the
  service, and onDestroy's scope.cancel() killed it before any status
  appeared; the upload is now part of finishRecording itself and the
  service stays foreground ("Saving recording…") until it completes
- JNI getTextSegment used NewStringUTF, which ABORTS THE PROCESS on
  invalid UTF-8 — whisper can emit garbled bytes on noisy audio (seen
  live: SIGABRT with illegal start byte 0x8d); whisper_jni.cpp now
  decodes UTF-8 to UTF-16 itself, replacing bad sequences with U+FFFD
- validated on device end-to-end: record -> local final pass -> automatic
  upload -> library entry with all four files (verified server-side)
This commit is contained in:
2026-09-08 11:42:00 +02:00
parent 6b697f8d94
commit 29bc4ef40b
2 changed files with 91 additions and 41 deletions
@@ -7,6 +7,7 @@
#include <android/log.h>
#include <jni.h>
#include <string.h>
#include <vector>
#include "whisper.h"
@@ -17,6 +18,60 @@ namespace {
whisper_context *as_ctx ( jlong ptr ) { return reinterpret_cast<whisper_context *> ( ptr ); }
// Decode possibly-invalid UTF-8 to UTF-16 and build a jstring. Whisper can
// emit garbled bytes for noise/hallucinations; JNIEnv::NewStringUTF would
// ABORT THE PROCESS on such input (Modified UTF-8 check), so we decode
// ourselves and replace bad sequences with U+FFFD.
jstring to_jstring ( JNIEnv *env, const char *s ) {
if ( s == nullptr ) {
return env->NewStringUTF ( "" );
}
const unsigned char *p = reinterpret_cast<const unsigned char *> ( s );
const size_t n = strlen ( s );
std::vector<jchar> out;
out.reserve ( n );
size_t i = 0;
auto is_cont = [] ( unsigned char c ) { return ( c & 0xC0 ) == 0x80; };
while ( i < n ) {
unsigned char c = p[ i ];
unsigned cp = 0xFFFFFFFF;
if ( c < 0x80 ) {
cp = c;
i += 1;
} else if ( ( c & 0xE0 ) == 0xC0 && i + 1 < n && is_cont ( p[ i + 1 ] ) ) {
cp = ( ( c & 0x1F ) << 6 ) | ( p[ i + 1 ] & 0x3F );
i += 2;
} else if ( ( c & 0xF0 ) == 0xE0 && i + 2 < n && is_cont ( p[ i + 1 ] ) && is_cont ( p[ i + 2 ] ) ) {
cp = ( ( c & 0x0F ) << 12 ) | ( ( p[ i + 1 ] & 0x3F ) << 6 ) | ( p[ i + 2 ] & 0x3F );
i += 3;
} else if ( ( c & 0xF8 ) == 0xF0 && i + 3 < n && is_cont ( p[ i + 1 ] ) && is_cont ( p[ i + 2 ] ) &&
is_cont ( p[ i + 3 ] ) ) {
cp = ( ( c & 0x07 ) << 18 ) | ( ( p[ i + 1 ] & 0x3F ) << 12 ) | ( ( p[ i + 2 ] & 0x3F ) << 6 ) |
( p[ i + 3 ] & 0x3F );
i += 4;
}
// reject overlong encodings, surrogates, out-of-range
bool ok =
cp != 0xFFFFFFFF && !( cp < 0x80 && c >= 0x80 ) && !( cp >= 0xD800 && cp <= 0xDFFF ) && cp <= 0x10FFFF;
if ( !ok ) {
out.push_back ( 0xFFFD ); // U+FFFD replacement character
i += 1;
continue;
}
if ( cp < 0x10000 ) {
out.push_back ( static_cast<jchar> ( cp ) );
} else { // surrogate pair for astral planes
cp -= 0x10000;
out.push_back ( static_cast<jchar> ( 0xD800 + ( cp >> 10 ) ) );
out.push_back ( static_cast<jchar> ( 0xDC00 + ( cp & 0x3FF ) ) );
}
}
if ( out.empty() ) {
return env->NewStringUTF ( "" );
}
return env->NewString ( out.data(), static_cast<jsize> ( out.size() ) );
}
} // namespace
extern "C" {
@@ -97,7 +152,7 @@ JNIEXPORT jlong JNICALL Java_com_meetrec_core_whisper_LibWhisper_getTextSegmentE
JNIEXPORT jstring JNICALL Java_com_meetrec_core_whisper_LibWhisper_getTextSegment ( JNIEnv *env, jobject, jlong ptr,
jint index ) {
return env->NewStringUTF ( whisper_full_get_segment_text ( as_ctx ( ptr ), index ) );
return to_jstring ( env, whisper_full_get_segment_text ( as_ctx ( ptr ), index ) );
}
} // extern "C"