|
|
@@ -0,0 +1,370 @@
|
|
|
+package com.codex.webviewgrammar
|
|
|
+
|
|
|
+import android.media.AudioAttributes
|
|
|
+import android.media.MediaDataSource
|
|
|
+import android.media.MediaPlayer
|
|
|
+import android.net.Uri
|
|
|
+import android.os.Bundle
|
|
|
+import android.util.Base64
|
|
|
+import android.view.inputmethod.EditorInfo
|
|
|
+import android.webkit.WebChromeClient
|
|
|
+import android.webkit.WebSettings
|
|
|
+import android.webkit.WebView
|
|
|
+import android.webkit.WebViewClient
|
|
|
+import android.widget.Toast
|
|
|
+import androidx.appcompat.app.AppCompatActivity
|
|
|
+import androidx.core.view.isVisible
|
|
|
+import androidx.lifecycle.lifecycleScope
|
|
|
+import com.codex.webviewgrammar.databinding.ActivityMainBinding
|
|
|
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
|
+import kotlinx.coroutines.Dispatchers
|
|
|
+import kotlinx.coroutines.launch
|
|
|
+import kotlinx.coroutines.suspendCancellableCoroutine
|
|
|
+import kotlinx.coroutines.withContext
|
|
|
+import okhttp3.MediaType.Companion.toMediaType
|
|
|
+import okhttp3.OkHttpClient
|
|
|
+import okhttp3.Request
|
|
|
+import okhttp3.RequestBody.Companion.toRequestBody
|
|
|
+import org.json.JSONArray
|
|
|
+import org.json.JSONException
|
|
|
+import org.json.JSONObject
|
|
|
+import java.io.IOException
|
|
|
+import java.util.ArrayDeque
|
|
|
+import java.util.concurrent.TimeUnit
|
|
|
+import kotlin.coroutines.resume
|
|
|
+
|
|
|
+class MainActivity : AppCompatActivity() {
|
|
|
+
|
|
|
+ private lateinit var binding: ActivityMainBinding
|
|
|
+ private val okHttpClient: OkHttpClient by lazy {
|
|
|
+ OkHttpClient.Builder()
|
|
|
+ .callTimeout(60, TimeUnit.SECONDS)
|
|
|
+ .connectTimeout(30, TimeUnit.SECONDS)
|
|
|
+ .readTimeout(60, TimeUnit.SECONDS)
|
|
|
+ .build()
|
|
|
+ }
|
|
|
+ private val ttsChunks: ArrayDeque<ByteArray> = ArrayDeque()
|
|
|
+ private var mediaPlayer: MediaPlayer? = null
|
|
|
+ private var mediaDataSource: MediaDataSource? = null
|
|
|
+ private var isTtsStreaming = false
|
|
|
+
|
|
|
+ override fun onCreate(savedInstanceState: Bundle?) {
|
|
|
+ super.onCreate(savedInstanceState)
|
|
|
+ binding = ActivityMainBinding.inflate(layoutInflater)
|
|
|
+ setContentView(binding.root)
|
|
|
+ setSupportActionBar(binding.toolbar)
|
|
|
+ setupWebView()
|
|
|
+ setupInteractions()
|
|
|
+ val homeUrl = getString(R.string.default_home_url)
|
|
|
+ binding.urlInput.setText(homeUrl)
|
|
|
+ loadUrl(homeUrl)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onDestroy() {
|
|
|
+ super.onDestroy()
|
|
|
+ clearTtsResources()
|
|
|
+ binding.webview.destroy()
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun setupInteractions() {
|
|
|
+ binding.loadButton.setOnClickListener { loadUrlFromInput() }
|
|
|
+ binding.urlInput.setOnEditorActionListener { _, actionId, _ ->
|
|
|
+ if (actionId == EditorInfo.IME_ACTION_GO) {
|
|
|
+ loadUrlFromInput()
|
|
|
+ true
|
|
|
+ } else {
|
|
|
+ false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ binding.highlightButton.setOnClickListener { triggerHighlight() }
|
|
|
+ binding.ttsButton.setOnClickListener { triggerTts() }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun setupWebView() {
|
|
|
+ binding.pageProgress.isIndeterminate = true
|
|
|
+ val settings = binding.webview.settings
|
|
|
+ settings.javaScriptEnabled = true
|
|
|
+ settings.domStorageEnabled = true
|
|
|
+ settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
|
|
+ settings.loadWithOverviewMode = true
|
|
|
+ settings.useWideViewPort = true
|
|
|
+ binding.webview.webChromeClient = object : WebChromeClient() {
|
|
|
+ override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
|
|
+ binding.pageProgress.isVisible = newProgress in 1 until 100
|
|
|
+ super.onProgressChanged(view, newProgress)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ binding.webview.webViewClient = object : WebViewClient() {
|
|
|
+ override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
|
|
+ super.onPageStarted(view, url, favicon)
|
|
|
+ setActionButtonsEnabled(false)
|
|
|
+ binding.statusText.text = getString(R.string.status_loading)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onPageFinished(view: WebView?, url: String?) {
|
|
|
+ super.onPageFinished(view, url)
|
|
|
+ setActionButtonsEnabled(true)
|
|
|
+ binding.statusText.text = getString(R.string.status_ready)
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun onReceivedError(
|
|
|
+ view: WebView?,
|
|
|
+ request: android.webkit.WebResourceRequest?,
|
|
|
+ error: android.webkit.WebResourceError?
|
|
|
+ ) {
|
|
|
+ super.onReceivedError(view, request, error)
|
|
|
+ setActionButtonsEnabled(false)
|
|
|
+ binding.statusText.text = error?.description?.toString() ?: getString(R.string.error_invalid_url)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun loadUrlFromInput() {
|
|
|
+ val input = binding.urlInput.text?.toString().orEmpty().trim()
|
|
|
+ if (input.isEmpty()) {
|
|
|
+ Toast.makeText(this, R.string.error_invalid_url, Toast.LENGTH_SHORT).show()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ val uri = ensureScheme(input)
|
|
|
+ loadUrl(uri.toString())
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun ensureScheme(candidate: String): Uri {
|
|
|
+ val parsed = Uri.parse(candidate)
|
|
|
+ return if (parsed.scheme.isNullOrEmpty()) {
|
|
|
+ Uri.parse("https://$candidate")
|
|
|
+ } else {
|
|
|
+ parsed
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun loadUrl(url: String) {
|
|
|
+ binding.webview.loadUrl(url)
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun triggerHighlight() {
|
|
|
+ lifecycleScope.launch {
|
|
|
+ val pageText = captureCurrentPageText()
|
|
|
+ if (pageText.isBlank()) {
|
|
|
+ binding.statusText.text = getString(R.string.error_empty_page)
|
|
|
+ return@launch
|
|
|
+ }
|
|
|
+ binding.statusText.text = getString(R.string.status_highlight_progress)
|
|
|
+ setActionButtonsEnabled(false)
|
|
|
+ val result = runCatching { requestHighlight(pageText) }
|
|
|
+ setActionButtonsEnabled(true)
|
|
|
+ result.onSuccess { html ->
|
|
|
+ binding.statusText.text = getString(R.string.status_ready)
|
|
|
+ showHighlightDialog(html)
|
|
|
+ }.onFailure { err ->
|
|
|
+ binding.statusText.text = getString(R.string.error_highlight_failed, err.message ?: "")
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun triggerTts() {
|
|
|
+ lifecycleScope.launch {
|
|
|
+ val pageText = captureCurrentPageText()
|
|
|
+ if (pageText.isBlank()) {
|
|
|
+ binding.statusText.text = getString(R.string.error_empty_page)
|
|
|
+ return@launch
|
|
|
+ }
|
|
|
+ binding.statusText.text = getString(R.string.status_tts_progress)
|
|
|
+ setActionButtonsEnabled(false)
|
|
|
+ resetTtsPlayback()
|
|
|
+ isTtsStreaming = true
|
|
|
+ val result = runCatching { streamTts(pageText) }
|
|
|
+ isTtsStreaming = false
|
|
|
+ setActionButtonsEnabled(true)
|
|
|
+ result.onFailure { err ->
|
|
|
+ binding.statusText.text = getString(R.string.error_tts_failed, err.message ?: "")
|
|
|
+ resetTtsPlayback()
|
|
|
+ }.onSuccess {
|
|
|
+ if (ttsChunks.isEmpty()) {
|
|
|
+ binding.statusText.text = getString(R.string.error_tts_failed, getString(R.string.error_empty_page))
|
|
|
+ } else {
|
|
|
+ binding.statusText.text = getString(R.string.status_tts_playing)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private suspend fun captureCurrentPageText(): String {
|
|
|
+ val script = """(function() { try { var root = document.body; if (!root) return ''; return root.innerText || root.textContent || ''; } catch (e) { return ''; } })();"""
|
|
|
+ val raw = evaluateJavascript(script)
|
|
|
+ return decodeJsResult(raw).trim()
|
|
|
+ }
|
|
|
+
|
|
|
+ private suspend fun evaluateJavascript(script: String): String? = suspendCancellableCoroutine { cont ->
|
|
|
+ binding.webview.evaluateJavascript(script) { value ->
|
|
|
+ if (!cont.isCompleted) {
|
|
|
+ cont.resume(value)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun decodeJsResult(raw: String?): String {
|
|
|
+ if (raw.isNullOrBlank() || raw == "null") return ""
|
|
|
+ return try {
|
|
|
+ JSONArray("[$raw]").getString(0)
|
|
|
+ } catch (_: JSONException) {
|
|
|
+ raw.replace("\"", "")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private suspend fun requestHighlight(text: String): String = withContext(Dispatchers.IO) {
|
|
|
+ val payload = JSONObject().put("text", text).toString()
|
|
|
+ val body = payload.toRequestBody("application/json; charset=utf-8".toMediaType())
|
|
|
+ val api = BuildConfig.GRAMMAR_API_BASE_URL.trimEnd('/') + "/analyze"
|
|
|
+ val request = Request.Builder()
|
|
|
+ .url(api)
|
|
|
+ .post(body)
|
|
|
+ .build()
|
|
|
+ okHttpClient.newCall(request).execute().use { response ->
|
|
|
+ if (!response.isSuccessful) {
|
|
|
+ throw IOException("HTTP ${'$'}{response.code}")
|
|
|
+ }
|
|
|
+ val responseBody = response.body?.string() ?: throw IOException("Empty response")
|
|
|
+ val json = JSONObject(responseBody)
|
|
|
+ json.getString("highlighted_html")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private suspend fun streamTts(text: String) = withContext(Dispatchers.IO) {
|
|
|
+ val payload = JSONObject().put("text", text).toString()
|
|
|
+ val body = payload.toRequestBody("application/json; charset=utf-8".toMediaType())
|
|
|
+ val request = Request.Builder()
|
|
|
+ .url(BuildConfig.TTS_ENDPOINT)
|
|
|
+ .post(body)
|
|
|
+ .build()
|
|
|
+ okHttpClient.newCall(request).execute().use { response ->
|
|
|
+ if (!response.isSuccessful) {
|
|
|
+ throw IOException("HTTP ${'$'}{response.code}")
|
|
|
+ }
|
|
|
+ val responseBody = response.body ?: throw IOException("Empty response")
|
|
|
+ val source = responseBody.source()
|
|
|
+ while (true) {
|
|
|
+ val line = source.readUtf8Line() ?: break
|
|
|
+ val chunk = parseTtsLine(line) ?: continue
|
|
|
+ withContext(Dispatchers.Main) {
|
|
|
+ enqueueChunk(chunk)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun parseTtsLine(raw: String?): ByteArray? {
|
|
|
+ if (raw.isNullOrBlank()) return null
|
|
|
+ var normalized = raw.trim()
|
|
|
+ if (normalized.startsWith("data:", ignoreCase = true)) {
|
|
|
+ normalized = normalized.substringAfter(":").trim()
|
|
|
+ }
|
|
|
+ if (normalized.isEmpty() || normalized == "[DONE]") {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ return try {
|
|
|
+ val json = JSONObject(normalized)
|
|
|
+ val audio = json.optString("audio")
|
|
|
+ if (audio.isNullOrBlank()) {
|
|
|
+ null
|
|
|
+ } else {
|
|
|
+ Base64.decode(audio, Base64.DEFAULT)
|
|
|
+ }
|
|
|
+ } catch (_: JSONException) {
|
|
|
+ null
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun enqueueChunk(chunk: ByteArray) {
|
|
|
+ ttsChunks.addLast(chunk)
|
|
|
+ if (mediaPlayer == null) {
|
|
|
+ playNextChunk()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun playNextChunk() {
|
|
|
+ if (ttsChunks.isEmpty()) {
|
|
|
+ if (!isTtsStreaming) {
|
|
|
+ binding.statusText.text = getString(R.string.status_tts_finished)
|
|
|
+ }
|
|
|
+ releasePlayer()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ val chunk = ttsChunks.removeFirst()
|
|
|
+ releasePlayer()
|
|
|
+ val player = MediaPlayer()
|
|
|
+ val dataSource = ByteArrayMediaDataSource(chunk)
|
|
|
+ mediaDataSource = dataSource
|
|
|
+ player.setAudioAttributes(
|
|
|
+ AudioAttributes.Builder()
|
|
|
+ .setUsage(AudioAttributes.USAGE_MEDIA)
|
|
|
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
|
+ .build()
|
|
|
+ )
|
|
|
+ player.setDataSource(dataSource)
|
|
|
+ player.setOnCompletionListener { playNextChunk() }
|
|
|
+ player.setOnErrorListener { _, _, _ ->
|
|
|
+ playNextChunk()
|
|
|
+ true
|
|
|
+ }
|
|
|
+ player.prepare()
|
|
|
+ player.start()
|
|
|
+ mediaPlayer = player
|
|
|
+ binding.statusText.text = getString(R.string.status_tts_playing)
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun resetTtsPlayback() {
|
|
|
+ ttsChunks.clear()
|
|
|
+ releasePlayer()
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun releasePlayer() {
|
|
|
+ mediaPlayer?.let {
|
|
|
+ runCatching { it.stop() }
|
|
|
+ it.release()
|
|
|
+ }
|
|
|
+ mediaPlayer = null
|
|
|
+ mediaDataSource?.close()
|
|
|
+ mediaDataSource = null
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun clearTtsResources() {
|
|
|
+ resetTtsPlayback()
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun setActionButtonsEnabled(enabled: Boolean) {
|
|
|
+ binding.highlightButton.isEnabled = enabled
|
|
|
+ binding.ttsButton.isEnabled = enabled
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun showHighlightDialog(html: String) {
|
|
|
+ val dialogWebView = WebView(this).apply {
|
|
|
+ settings.javaScriptEnabled = false
|
|
|
+ settings.defaultTextEncodingName = "utf-8"
|
|
|
+ loadDataWithBaseURL(null, html, "text/html", "utf-8", null)
|
|
|
+ }
|
|
|
+ MaterialAlertDialogBuilder(this)
|
|
|
+ .setTitle(R.string.dialog_highlight_title)
|
|
|
+ .setView(dialogWebView)
|
|
|
+ .setPositiveButton(R.string.action_close, null)
|
|
|
+ .show()
|
|
|
+ }
|
|
|
+
|
|
|
+ private class ByteArrayMediaDataSource(private val data: ByteArray) : MediaDataSource() {
|
|
|
+ override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
|
|
+ if (position >= data.size) {
|
|
|
+ return -1
|
|
|
+ }
|
|
|
+ val available = data.size - position.toInt()
|
|
|
+ val length = minOf(available, size)
|
|
|
+ System.arraycopy(data, position.toInt(), buffer, offset, length)
|
|
|
+ return length
|
|
|
+ }
|
|
|
+
|
|
|
+ override fun getSize(): Long = data.size.toLong()
|
|
|
+
|
|
|
+ override fun close() {
|
|
|
+ // nothing to release
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|