Native HTTP POST to /api/transcribe — bypasses evalJs size limit, uses WebView cookies for auth
This commit is contained in:
@@ -2,22 +2,27 @@ package com.hermes.app
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* JavaScript interface exposed to the Hermes WebUI as `HermesNative`.
|
||||
@@ -26,17 +31,18 @@ import java.util.Locale
|
||||
class HermesWebAppInterface(private val context: Context) {
|
||||
|
||||
private var tts: TextToSpeech? = null
|
||||
private var speechRecognizer: SpeechRecognizer? = null
|
||||
private var webView: WebView? = null
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
private var audioFile: File? = null
|
||||
private var currentCallbackId: String? = null
|
||||
private var isRecording = false
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val callbackPrefix = "window.__hermesDictationCallback"
|
||||
|
||||
fun setWebView(wv: WebView) {
|
||||
webView = wv
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device info for analytics/debugging.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun getDeviceInfo(): String {
|
||||
return """
|
||||
@@ -50,9 +56,6 @@ class HermesWebAppInterface(private val context: Context) {
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using TTS.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun speak(text: String) {
|
||||
if (tts == null) {
|
||||
@@ -69,9 +72,6 @@ class HermesWebAppInterface(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibrate the device.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun vibrate(durationMs: Int) {
|
||||
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -96,17 +96,11 @@ class HermesWebAppInterface(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast message.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun toast(message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the app badge count.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun setBadge(count: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -130,10 +124,6 @@ class HermesWebAppInterface(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a native notification from the web page.
|
||||
* Called via HermesNative.notify(title, body)
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun notify(title: String, body: String) {
|
||||
try {
|
||||
@@ -154,85 +144,158 @@ class HermesWebAppInterface(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start native speech recognition and return result via callback.
|
||||
* Start audio recording. Records until stopDictation() is called.
|
||||
* Called from JS: HermesNative.startDictation(callbackId)
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun startDictation(callbackId: String) {
|
||||
try {
|
||||
if (speechRecognizer != null) {
|
||||
speechRecognizer?.destroy()
|
||||
speechRecognizer = null
|
||||
}
|
||||
android.util.Log.d("HermesWV", "startDictation: $callbackId")
|
||||
currentCallbackId = callbackId
|
||||
isRecording = true
|
||||
|
||||
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context)
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
try {
|
||||
// Ensure any previous recognizer/recorder is fully torn down
|
||||
try { mediaRecorder?.apply { stop(); release() } } catch (_: Exception) {}
|
||||
mediaRecorder = null
|
||||
|
||||
speechRecognizer?.setRecognitionListener(object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
evalJs("$callbackPrefix('$callbackId', 'READY', '')")
|
||||
}
|
||||
|
||||
override fun onBeginningOfSpeech() {}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {}
|
||||
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
|
||||
override fun onEndOfSpeech() {}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
val msg = when (error) {
|
||||
SpeechRecognizer.ERROR_NO_MATCH -> "NO_MATCH"
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "TIMEOUT"
|
||||
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "BUSY"
|
||||
else -> "ERROR"
|
||||
audioFile = File(context.cacheDir, "hermes_audio_${UUID.randomUUID()}.webm")
|
||||
mediaRecorder = MediaRecorder().apply {
|
||||
// Use VOICE_COMMUNICATION on Samsung — MIC can route differently
|
||||
setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.WEBM)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
|
||||
setAudioSamplingRate(16000)
|
||||
setAudioChannels(1)
|
||||
setAudioEncodingBitRate(16000)
|
||||
setOutputFile(audioFile!!.absolutePath)
|
||||
try { prepare() } catch (e: Exception) {
|
||||
android.util.Log.e("HermesWV", "prepare failed: ${e.message}")
|
||||
evalJs("$callbackPrefix('$callbackId', 'ERROR', 'prepare: ${e.message?.replace("'", "\\'") ?: "unknown"}')")
|
||||
return@post
|
||||
}
|
||||
evalJs("$callbackPrefix('$callbackId', '$msg', '')")
|
||||
speechRecognizer?.destroy()
|
||||
speechRecognizer = null
|
||||
try { start() } catch (e: Exception) {
|
||||
android.util.Log.e("HermesWV", "start failed: ${e.message}")
|
||||
evalJs("$callbackPrefix('$callbackId', 'ERROR', 'start: ${e.message?.replace("'", "\\'") ?: "unknown"}')")
|
||||
return@post
|
||||
}
|
||||
}
|
||||
android.util.Log.d("HermesWV", "Recording started: ${audioFile!!.path}")
|
||||
evalJs("$callbackPrefix('$callbackId', 'RECORDING', '')")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.d("HermesWV", "Record start error: ${e.message}")
|
||||
isRecording = false
|
||||
evalJs("$callbackPrefix('$callbackId', 'ERROR', '${e.message?.replace("'", "\\'") ?: "unknown"}')")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording and send audio to server for transcription.
|
||||
* Called from JS: HermesNative.stopDictation()
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun stopDictation() {
|
||||
android.util.Log.d("HermesWV", "stopDictation")
|
||||
val id = currentCallbackId ?: return
|
||||
isRecording = false
|
||||
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
try {
|
||||
mediaRecorder?.apply {
|
||||
stop()
|
||||
release()
|
||||
}
|
||||
mediaRecorder = null
|
||||
android.util.Log.d("HermesWV", "Recording stopped")
|
||||
|
||||
// Send audio to server for transcription
|
||||
val file = audioFile ?: return@post
|
||||
android.util.Log.d("HermesWV", "Audio file size: ${file.length()} bytes at ${file.absolutePath}")
|
||||
if (!file.exists() || file.length() == 0L) {
|
||||
evalJs("$callbackPrefix('$id', 'NO_AUDIO', '')")
|
||||
file.delete()
|
||||
return@post
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
val texts = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
val text = texts?.firstOrNull() ?: ""
|
||||
val safeText = text.replace("'", "\\'").replace("\n", " ")
|
||||
evalJs("$callbackPrefix('$callbackId', 'RESULT', '$safeText')")
|
||||
speechRecognizer?.destroy()
|
||||
speechRecognizer = null
|
||||
evalJs("$callbackPrefix('$id', 'TRANSCRIBING', '')")
|
||||
executor.execute { transcribeAudio(id, file) }
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.d("HermesWV", "Record stop error: ${e.message}")
|
||||
evalJs("$callbackPrefix('$id', 'ERROR', '${e.message?.replace("'", "\\'") ?: "unknown"}')")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun transcribeAudio(callbackId: String, file: File) {
|
||||
try {
|
||||
val fileBytes = file.readBytes()
|
||||
if (fileBytes.isEmpty()) {
|
||||
evalJs("$callbackPrefix('$callbackId', 'NO_AUDIO', '')")
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
|
||||
// POST audio directly to server from native code — avoids evalJs size limits
|
||||
val prefs = context.getSharedPreferences(AppConstants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val serverUrl = prefs.getString(AppConstants.PREFS_SERVER_URL, AppConstants.DEFAULT_SERVER_URL)
|
||||
?: AppConstants.DEFAULT_SERVER_URL
|
||||
val url = URL("$serverUrl/api/transcribe")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.doOutput = true
|
||||
conn.setRequestProperty("Content-Type", "audio/webm")
|
||||
conn.connectTimeout = 30000
|
||||
conn.readTimeout = 60000
|
||||
|
||||
// Copy auth cookies from WebView session
|
||||
try {
|
||||
val cookieMgr = android.webkit.CookieManager.getInstance()
|
||||
val cookies = cookieMgr.getCookie(serverUrl)
|
||||
if (!cookies.isNullOrEmpty()) {
|
||||
conn.setRequestProperty("Cookie", cookies)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
val texts = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
val text = texts?.firstOrNull() ?: ""
|
||||
val safeText = text.replace("'", "\\'").replace("\n", " ")
|
||||
evalJs("$callbackPrefix('$callbackId', 'PARTIAL', '$safeText')")
|
||||
}
|
||||
conn.outputStream.use { it.write(fileBytes) }
|
||||
|
||||
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||
})
|
||||
val responseCode = conn.responseCode
|
||||
android.util.Log.d("HermesWV", "Transcribe HTTP $responseCode")
|
||||
|
||||
speechRecognizer?.startListening(intent)
|
||||
if (responseCode == 200) {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
android.util.Log.d("HermesWV", "Transcribe OK: $body")
|
||||
val text = try {
|
||||
val json = org.json.JSONObject(body)
|
||||
json.optString("text", json.optString("transcript", body))
|
||||
} catch (_: Exception) { body }
|
||||
val safeText = text.replace("'", "\\'").replace("\n", " ")
|
||||
evalJs("$callbackPrefix('$callbackId', 'RESULT', '$safeText')")
|
||||
} else {
|
||||
val errBody = try { conn.errorStream.bufferedReader().readText() } catch (_: Exception) { "HTTP $responseCode" }
|
||||
android.util.Log.d("HermesWV", "Transcribe failed: $errBody")
|
||||
evalJs("$callbackPrefix('$callbackId', 'ERROR', '${errBody.replace("'", "\\'")}')")
|
||||
}
|
||||
conn.disconnect()
|
||||
} catch (e: Exception) {
|
||||
val safeMsg = (e.message ?: "unknown").replace("'", "\\'")
|
||||
evalJs("$callbackPrefix('$callbackId', 'ERROR', '$safeMsg')")
|
||||
android.util.Log.e("HermesWV", "Transcribe exception: ${e.message}")
|
||||
evalJs("$callbackPrefix('$callbackId', 'ERROR', '${e.message?.replace("'", "\\'") ?: "unknown"}')")
|
||||
} finally {
|
||||
file.delete()
|
||||
audioFile = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun evalJs(js: String) {
|
||||
try {
|
||||
webView?.post { webView?.evaluateJavascript(js, null) }
|
||||
webView?.evaluateJavascript(js, null)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up TTS resources.
|
||||
*/
|
||||
fun shutdown() {
|
||||
try {
|
||||
mediaRecorder?.apply { stop(); release() }
|
||||
} catch (_: Exception) {}
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermes.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
@@ -13,11 +14,18 @@ import android.widget.Toast
|
||||
*/
|
||||
class HermesWebViewManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
const val FILE_CHOOSER_REQUEST_CODE = 10001
|
||||
}
|
||||
|
||||
var webView: WebView? = null
|
||||
var onPageTitleChanged: ((String) -> Unit)? = null
|
||||
var onPageLoading: ((Boolean) -> Unit)? = null
|
||||
var onConnectionLost: (() -> Unit)? = null
|
||||
|
||||
// Stored file chooser callback — Activity intercepts in onActivityResult
|
||||
var filePathCallback: ValueCallback<Array<Uri>>? = null
|
||||
|
||||
fun createWebView(): WebView {
|
||||
return WebView(context).apply {
|
||||
layoutParams = android.view.ViewGroup.LayoutParams(
|
||||
@@ -88,37 +96,62 @@ class HermesWebViewManager(private val context: Context) {
|
||||
return origQuery(desc);
|
||||
};
|
||||
}
|
||||
// Hook into WebUI mic system via native SpeechRecognizer
|
||||
if (window.HermesNative && window.HermesNative.startDictation) {
|
||||
// Hook into WebUI mic system via native AudioRecord
|
||||
if (window.HermesNative && window.HermesNative.startDictation && window.HermesNative.stopDictation) {
|
||||
console.log('[Hermes] Native audio record available');
|
||||
var _dictationCallbackId = 0;
|
||||
var _dictationCallbacks = {};
|
||||
window.__hermesDictationCallback = function(id, status, text) {
|
||||
console.log('[Hermes] Dictation cb:', id, status, text);
|
||||
var cb = _dictationCallbacks[id];
|
||||
if (cb) { cb(status, text); delete _dictationCallbacks[id]; }
|
||||
};
|
||||
// Intercept the mic toggle function
|
||||
var origToggle = window._toggleMicCapture;
|
||||
if (origToggle) {
|
||||
window._toggleMicCapture = function() {
|
||||
if (window.__hermesDictating) {
|
||||
window.__hermesDictating = false;
|
||||
return;
|
||||
}
|
||||
var id = 'd' + (++_dictationCallbackId);
|
||||
window.__hermesDictating = true;
|
||||
_dictationCallbacks[id] = function(status, text) {
|
||||
if (status === 'RESULT') {
|
||||
var ta = document.getElementById('msg');
|
||||
if (ta) {
|
||||
ta.value += text;
|
||||
ta.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
// Replace mic button to reroute via native audio record
|
||||
var btn = document.getElementById('btnMic');
|
||||
if (btn && !btn.dataset.hermesNative) {
|
||||
console.log('[Hermes] Replacing mic button');
|
||||
btn.dataset.hermesNative = '1';
|
||||
var newBtn = btn.cloneNode(true);
|
||||
btn.parentNode.replaceChild(newBtn, btn);
|
||||
var _nativeRecording = false;
|
||||
var _nativeCbId = null;
|
||||
newBtn.addEventListener('pointerdown', function(e) {
|
||||
console.log('[Hermes] Mic button pressed, recording:', _nativeRecording);
|
||||
if (!_nativeRecording) {
|
||||
// Start recording
|
||||
_nativeRecording = true;
|
||||
var id = 'd' + (++_dictationCallbackId);
|
||||
_nativeCbId = id;
|
||||
_dictationCallbacks[id] = function(status, text) {
|
||||
console.log('[Hermes] Dictation result:', status, text);
|
||||
if (status === 'RECORDING') {
|
||||
// Recording started, mic stays active
|
||||
} else if (status === 'RESULT') {
|
||||
var ta = document.getElementById('msg');
|
||||
if (ta) {
|
||||
ta.value += text;
|
||||
ta.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}
|
||||
_nativeRecording = false;
|
||||
} else if (status === 'TRANSCRIBING') {
|
||||
// Server is processing
|
||||
} else {
|
||||
_nativeRecording = false;
|
||||
}
|
||||
}
|
||||
window.__hermesDictating = false;
|
||||
};
|
||||
HermesNative.startDictation(id);
|
||||
};
|
||||
};
|
||||
console.log('[Hermes] startDictation id=' + id);
|
||||
HermesNative.startDictation(id);
|
||||
} else {
|
||||
// Stop recording and transcribe
|
||||
console.log('[Hermes] stopDictation');
|
||||
HermesNative.stopDictation();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('[Hermes] Mic button not found or already native');
|
||||
}
|
||||
} else {
|
||||
console.log('[Hermes] Native audio record NOT available');
|
||||
}
|
||||
})()
|
||||
""".trimIndent(), null
|
||||
@@ -148,8 +181,6 @@ class HermesWebViewManager(private val context: Context) {
|
||||
handler: SslErrorHandler?,
|
||||
error: SslError?
|
||||
) {
|
||||
// Accept self-signed cert for LAN Hermes server
|
||||
// We control both ends — this is safe
|
||||
handler?.proceed()
|
||||
}
|
||||
|
||||
@@ -160,7 +191,6 @@ class HermesWebViewManager(private val context: Context) {
|
||||
val url = request?.url ?: return false
|
||||
val host = url.host ?: return false
|
||||
val serverHost = Uri.parse(getServerUrl())?.host ?: "10.0.1.49"
|
||||
|
||||
if (host != serverHost && !host.endsWith(".local")) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, url)
|
||||
context.startActivity(intent)
|
||||
@@ -177,10 +207,32 @@ class HermesWebViewManager(private val context: Context) {
|
||||
|
||||
override fun onPermissionRequest(request: PermissionRequest?) {
|
||||
val resources = request?.resources ?: emptyArray()
|
||||
// Grant every permission the WebView asks for
|
||||
// (notification, microphone, camera — we control both ends)
|
||||
request?.grant(resources)
|
||||
}
|
||||
|
||||
override fun onConsoleMessage(message: ConsoleMessage?): Boolean {
|
||||
android.util.Log.d("HermesWV", "${message?.message()} (${message?.sourceId()}:${message?.lineNumber()})")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onShowFileChooser(
|
||||
webView: WebView?,
|
||||
filePathCallback: ValueCallback<Array<Uri>>?,
|
||||
fileChooserParams: FileChooserParams?
|
||||
): Boolean {
|
||||
val intent = fileChooserParams?.createIntent() ?: return false
|
||||
this@HermesWebViewManager.filePathCallback = filePathCallback
|
||||
try {
|
||||
(context as Activity).startActivityForResult(
|
||||
Intent.createChooser(intent, "Select file"),
|
||||
FILE_CHOOSER_REQUEST_CODE
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
this@HermesWebViewManager.filePathCallback = null
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength ->
|
||||
@@ -196,6 +248,19 @@ class HermesWebViewManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun onFilePickerResult(resultCode: Int, data: Intent?) {
|
||||
val cb = filePathCallback ?: return
|
||||
filePathCallback = null
|
||||
if (resultCode == Activity.RESULT_OK && data?.data != null) {
|
||||
cb.onReceiveValue(arrayOf(data.data!!))
|
||||
} else if (resultCode == Activity.RESULT_OK && data?.clipData != null) {
|
||||
val uris = Array(data.clipData!!.itemCount) { i -> data.clipData!!.getItemAt(i).uri }
|
||||
cb.onReceiveValue(uris)
|
||||
} else {
|
||||
cb.onReceiveValue(null)
|
||||
}
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
webView?.reload()
|
||||
}
|
||||
|
||||
@@ -195,6 +195,13 @@ class MainActivity : ComponentActivity() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == HermesWebViewManager.FILE_CHOOSER_REQUEST_CODE) {
|
||||
webViewManager.onFilePickerResult(resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && webView?.canGoBack() == true) {
|
||||
webView?.goBack()
|
||||
|
||||
Reference in New Issue
Block a user