Gogs 1 mese fa
commit
56c2db1283

+ 28 - 0
.gitignore

@@ -0,0 +1,28 @@
+# Gradle and build artifacts
+.gradle/
+build/
+app/build/
+app/release/
+app/debug/
+*.apk
+*.aab
+*.ap_ 
+*.dex
+
+# Local configuration
+local.properties
+*.keystore
+*.jks
+
+# IDE specific
+.idea/
+*.iml
+*.ipr
+*.iws
+.DS_Store
+
+# Generated/temporary
+captures/
+.cxx/
+.externalNativeBuild/
+*.log

+ 62 - 0
app/build.gradle

@@ -0,0 +1,62 @@
+plugins {
+    id "com.android.application"
+    id "org.jetbrains.kotlin.android"
+}
+
+def grammarApiBaseUrl = project.findProperty("grammarApiBaseUrl") ?: "http://aimanyi.top"
+def ttsEndpointUrl = project.findProperty("ttsEndpointUrl") ?: "http://141.140.15.30:8028/generate"
+
+android {
+    namespace "com.codex.webviewgrammar"
+    compileSdk 34
+
+    defaultConfig {
+        applicationId "com.codex.webviewgrammar"
+        minSdk 24
+        targetSdk 34
+        versionCode 1
+        versionName "1.0"
+
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        buildConfigField "String", "GRAMMAR_API_BASE_URL", "\"${grammarApiBaseUrl}\""
+        buildConfigField "String", "TTS_ENDPOINT", "\"${ttsEndpointUrl}\""
+    }
+
+    buildTypes {
+        release {
+            minifyEnabled = false
+            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+        }
+        debug {
+            minifyEnabled = false
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+
+    buildFeatures {
+        viewBinding = true
+        buildConfig = true
+    }
+}
+
+dependencies {
+    implementation "androidx.core:core-ktx:1.13.1"
+    implementation "androidx.appcompat:appcompat:1.7.0"
+    implementation "com.google.android.material:material:1.12.0"
+    implementation "androidx.constraintlayout:constraintlayout:2.1.4"
+    implementation "androidx.activity:activity-ktx:1.9.2"
+    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.5"
+    implementation "com.squareup.okhttp3:okhttp:4.12.0"
+    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
+
+    testImplementation "junit:junit:4.13.2"
+    androidTestImplementation "androidx.test.ext:junit:1.2.1"
+    androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1"
+}

+ 3 - 0
app/proguard-rules.pro

@@ -0,0 +1,3 @@
+# Keep OkHttp and Kotlin coroutines metadata used via reflection
+-keepattributes SourceFile,LineNumberTable
+-dontwarn kotlin.**

+ 25 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,25 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <uses-permission android:name="android.permission.INTERNET" />
+
+    <application
+        android:allowBackup="true"
+        android:dataExtractionRules="@xml/data_extraction_rules"
+        android:fullBackupContent="@xml/backup_rules"
+        android:icon="@drawable/ic_launcher_foreground"
+        android:label="@string/app_name"
+        android:roundIcon="@drawable/ic_launcher_foreground"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.WebviewGrammar"
+        android:usesCleartextTraffic="true">
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:configChanges="keyboardHidden|orientation|screenSize"
+            android:screenOrientation="unspecified">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>

+ 370 - 0
app/src/main/java/com/codex/webviewgrammar/MainActivity.kt

@@ -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
+        }
+    }
+}

+ 5 - 0
app/src/main/res/drawable/ic_launcher_background.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <solid android:color="#0F5AE0" />
+</shape>

+ 24 - 0
app/src/main/res/drawable/ic_launcher_foreground.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <group android:scaleX="0.6" android:scaleY="0.6" android:translateX="21.6" android:translateY="21.6">
+        <path
+            android:fillColor="#FFFFFF"
+            android:pathData="M54,4c27.61,0 50,22.39 50,50s-22.39,50 -50,50 -50,-22.39 -50,-50 22.39,-50 50,-50z" />
+        <path
+            android:fillColor="#0F5AE0"
+            android:pathData="M28,30h52c2.21,0 4,1.79 4,4v40c0,2.21 -1.79,4 -4,4H28c-2.21,0 -4,-1.79 -4,-4V34c0,-2.21 1.79,-4 4,-4z" />
+        <path
+            android:fillColor="#FFFFFF"
+            android:pathData="M34,38h40v4H34zM34,48h28v4H34zM34,58h22v4H34z" />
+        <path
+            android:fillColor="#FFFFFF"
+            android:pathData="M64,64l10,10 10,-10" />
+        <path
+            android:fillColor="#FFFFFF"
+            android:pathData="M74,70v10h-4v-10z" />
+    </group>
+</vector>

+ 89 - 0
app/src/main/res/layout/activity_main.xml

@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:padding="16dp">
+
+    <com.google.android.material.appbar.MaterialToolbar
+        android:id="@+id/toolbar"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:paddingStart="0dp"
+        android:paddingEnd="0dp"
+        android:elevation="0dp"
+        app:title="@string/app_name"
+        app:titleCentered="false" />
+
+    <com.google.android.material.textfield.TextInputLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="12dp"
+        app:endIconMode="clear_text">
+
+        <com.google.android.material.textfield.TextInputEditText
+            android:id="@+id/urlInput"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:imeOptions="actionGo"
+            android:inputType="textUri"
+            android:hint="@string/url_hint" />
+    </com.google.android.material.textfield.TextInputLayout>
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/loadButton"
+        style="@style/Widget.Material3.Button"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:text="@string/action_load" />
+
+    <com.google.android.material.progressindicator.LinearProgressIndicator
+        android:id="@+id/pageProgress"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="8dp"
+        android:visibility="gone" />
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="12dp"
+        android:gravity="center"
+        android:orientation="horizontal">
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/highlightButton"
+            style="@style/Widget.Material3.Button"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="8dp"
+            android:layout_weight="1"
+            android:enabled="false"
+            android:text="@string/action_highlight" />
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/ttsButton"
+            style="@style/Widget.Material3.Button"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:enabled="false"
+            android:text="@string/action_tts" />
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/statusText"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="4dp"
+        android:text="@string/status_idle" />
+
+    <android.webkit.WebView
+        android:id="@+id/webview"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_marginTop="12dp"
+        android:layout_weight="1" />
+</LinearLayout>

+ 11 - 0
app/src/main/res/values/colors.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="md_theme_primary">#0F5AE0</color>
+    <color name="md_theme_onPrimary">#FFFFFF</color>
+    <color name="md_theme_secondary">#4A5568</color>
+    <color name="md_theme_onSecondary">#FFFFFF</color>
+    <color name="md_theme_background">#FFFFFF</color>
+    <color name="md_theme_onBackground">#1F2933</color>
+    <color name="md_theme_surface">#FFFFFF</color>
+    <color name="md_theme_onSurface">#1F2933</color>
+</resources>

+ 22 - 0
app/src/main/res/values/strings.xml

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">WebView Grammar</string>
+    <string name="url_hint">输入要访问的网址</string>
+    <string name="action_load">加载网页</string>
+    <string name="action_highlight">语法高亮</string>
+    <string name="action_tts">TTS 朗读</string>
+    <string name="status_idle">等待操作</string>
+    <string name="status_loading">正在加载页面…</string>
+    <string name="status_ready">页面加载完成,可进行语法高亮或朗读</string>
+    <string name="status_highlight_progress">正在调用语法高亮接口…</string>
+    <string name="status_tts_progress">正在请求语音…</string>
+    <string name="status_tts_playing">正在朗读…</string>
+    <string name="status_tts_finished">朗读完成</string>
+    <string name="dialog_highlight_title">语法高亮结果</string>
+    <string name="error_invalid_url">请输入正确的网址</string>
+    <string name="error_empty_page">无法获取页面正文,请尝试打开其他网页</string>
+    <string name="error_highlight_failed">语法高亮失败:%1$s</string>
+    <string name="error_tts_failed">TTS 出错:%1$s</string>
+    <string name="action_close">关闭</string>
+    <string name="default_home_url">https://www.example.com</string>
+</resources>

+ 11 - 0
app/src/main/res/values/themes.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools">
+    <style name="Theme.WebviewGrammar" parent="Theme.Material3.DayNight.NoActionBar">
+        <item name="colorPrimary">@color/md_theme_primary</item>
+        <item name="colorOnPrimary">@color/md_theme_onPrimary</item>
+        <item name="colorSecondary">@color/md_theme_secondary</item>
+        <item name="colorOnSecondary">@color/md_theme_onSecondary</item>
+        <item name="android:statusBarColor" tools:targetApi="l">@color/md_theme_primary</item>
+        <item name="android:navigationBarColor" tools:targetApi="o">@color/md_theme_background</item>
+    </style>
+</resources>

+ 4 - 0
app/src/main/res/xml/backup_rules.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<full-backup-content>
+    <exclude domain="device" />
+</full-backup-content>

+ 9 - 0
app/src/main/res/xml/data_extraction_rules.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<data-extraction-rules>
+    <cloud-backup>
+        <exclude domain="device" />
+    </cloud-backup>
+    <device-transfer>
+        <exclude domain="device" />
+    </device-transfer>
+</data-extraction-rules>

+ 4 - 0
build.gradle

@@ -0,0 +1,4 @@
+plugins {
+    id "com.android.application" version "8.5.0" apply false
+    id "org.jetbrains.kotlin.android" version "1.9.24" apply false
+}

+ 4 - 0
gradle.properties

@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official

BIN
gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 249 - 0
gradlew

@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"

+ 92 - 0
gradlew.bat

@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 18 - 0
settings.gradle

@@ -0,0 +1,18 @@
+pluginManagement {
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "WebviewGrammarTts"
+include(":app")