diff --git a/.gitignore b/.gitignore index c863a72b..0e882c2f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ src-tauri/gen/* *.node -content \ No newline at end of file +content +debugging \ No newline at end of file diff --git a/README.md b/README.md index 9264e17c..9ab4b082 100644 --- a/README.md +++ b/README.md @@ -134,31 +134,6 @@ Compared to the original, Risuko v0.3.0 has: - ~70% less memory usage (taking nearest tenth, ~425MB -> ~125MB) - ~90% less peak CPU usage (~145% -> ~15%) -This is achieved by using rust build params: -``` -[profile.release] -opt-level = 3 -strip = "symbols" -lto = true -codegen-units = 1 -panic = "abort" -``` -It tells `rustc` to prioritize the binary over patience: - -`opt-level = 3` -Enables every LLVM optimization pass. builds get noticeably slower; program gets faster -`strip = "symbols"` -Strips debug symbols before shipping. The file shrinks, but if it crashes in production, we're staring at assembly -`lto = true` -Link Time Optimization across all crates. LLVM inlines across boundaries and deletes dead code -`codegen-units = 1` -Forces the compiler to use a single translation unit. No parallel codegen, but LLVM sees the whole program for better optimization -`panic = "abort"` -Crash immediately on panic—no unwinding, no cleanup. Smaller binaries, but destructors don't run - -The small bundle size, cpu and memory performance is also acheived by removing aria2, and replace by native rust codes. - - | Original | Next | Risuko v0.3.0 | | ------- | ---- | ----------- | | ![orignal_mem](./static/readme/Original_Memory.png) | ![0.0.4_mem](./static/readme/v0.0.4_Memory.png) | ![0.3.0_mem](./static/readme/v0.3.0_Memory.png) | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76415e71..df05757d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1280,8 +1280,8 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - nanoid@3.3.13: - resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==} + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2563,7 +2563,7 @@ snapshots: muggle-string@0.4.1: {} - nanoid@3.3.13: {} + nanoid@3.3.15: {} node-addon-api@7.1.1: optional: true @@ -2611,7 +2611,7 @@ snapshots: postcss@8.5.15: dependencies: - nanoid: 3.3.13 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d9fe925c..48987b62 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1121,9 +1121,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97bb4a855e3b10f84c4e7e895a7de01db7f9a7b7eb7f73ed9773fd52ac686451" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ "cpubits", "ctutils", @@ -4447,9 +4447,9 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "primefield" -version = "0.14.0-rc.12" +version = "0.14.0-rc.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8675564771a62f69a0af716b03e89b917b963c7b173b5855575e84fd4f605ca0" +checksum = "2db02b39ea98560a1fec81df6266f3c1ef7fdde06ac5ef17f69aee6101602630" dependencies = [ "crypto-bigint", "crypto-common 0.2.2", @@ -4611,9 +4611,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -4871,6 +4871,7 @@ dependencies = [ "risuko-cookies", "risuko-engine", "risuko-http", + "risuko-webview-upgrade", "serde", "serde_json", "sha1 0.11.0", @@ -5077,6 +5078,14 @@ dependencies = [ "tracing", ] +[[package]] +name = "risuko-webview-upgrade" +version = "0.4.2" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -5256,9 +5265,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "log", "once_cell", @@ -6639,9 +6648,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.49" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", "num-conv", @@ -6659,9 +6668,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.29" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 63f96425..99485bf8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["risuko-engine", "risuko-bt", "risuko-cli", "risuko-napi", "risuko-http", "risuko-cookies"] +members = ["risuko-engine", "risuko-bt", "risuko-cli", "risuko-napi", "risuko-http", "risuko-cookies", "risuko-webview-upgrade"] [workspace.package] version = "0.4.2" @@ -12,6 +12,7 @@ risuko-engine = { path = "risuko-engine" } risuko-bt = { path = "risuko-bt" } risuko-http = { path = "risuko-http" } risuko-cookies = { path = "risuko-cookies" } +risuko-webview-upgrade = { path = "risuko-webview-upgrade" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["process", "sync", "time", "rt", "net", "macros", "signal", "io-util"] } @@ -101,5 +102,8 @@ tauri-plugin-single-instance = "2" # wildcard-MIME apps like Messages intercept any content URI we send. jni = "0.22" ndk-context = "0.1" +# Wires the Android WebView kernel-upgrade library into the Gradle build (via +# its `links` key) and provides the no-op `init()` registered in `run()`. +risuko-webview-upgrade = { workspace = true } [dev-dependencies] diff --git a/src-tauri/risuko-cookies/src/browser/mod.rs b/src-tauri/risuko-cookies/src/browser/mod.rs index e4e27dde..37ba8168 100644 --- a/src-tauri/risuko-cookies/src/browser/mod.rs +++ b/src-tauri/risuko-cookies/src/browser/mod.rs @@ -1,4 +1,7 @@ +#[cfg(not(target_os = "android"))] pub mod chromium; + +#[cfg(not(target_os = "android"))] pub mod firefox; #[cfg(target_os = "macos")] diff --git a/src-tauri/risuko-cookies/src/lib.rs b/src-tauri/risuko-cookies/src/lib.rs index 74de5771..f775f6b4 100644 --- a/src-tauri/risuko-cookies/src/lib.rs +++ b/src-tauri/risuko-cookies/src/lib.rs @@ -30,6 +30,7 @@ pub struct Cookie { pub expires: Option, } +#[cfg(not(target_os = "android"))] impl From for Cookie { fn from(c: browser::chromium::Cookie) -> Self { Self { diff --git a/src-tauri/risuko-cookies/src/utils/mod.rs b/src-tauri/risuko-cookies/src/utils/mod.rs index e17c85a5..d1a0deba 100644 --- a/src-tauri/risuko-cookies/src/utils/mod.rs +++ b/src-tauri/risuko-cookies/src/utils/mod.rs @@ -1,2 +1,4 @@ +#[cfg(not(target_os = "android"))] pub mod paths; +#[cfg(not(target_os = "android"))] pub mod time; diff --git a/src-tauri/risuko-webview-upgrade/.gitignore b/src-tauri/risuko-webview-upgrade/.gitignore new file mode 100644 index 00000000..be87ba69 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/.gitignore @@ -0,0 +1,5 @@ +/android/.gradle +/android/build +/android/.tauri +/android/local.properties +/android/src/main/java/app/risuko/webview_upgrade/WebViewUpgradeConfig.kt diff --git a/src-tauri/risuko-webview-upgrade/Cargo.toml b/src-tauri/risuko-webview-upgrade/Cargo.toml new file mode 100644 index 00000000..3b8aec97 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "risuko-webview-upgrade" +description = "Swap the in-process Android WebView kernel to a newer installed Chrome / Google WebView when the system WebView is too old to render Risuko." +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +# `links` is the hook that makes the host app's `tauri-build` discover this +# plugin's Android library and wire it into the generated Gradle project. It +# must be unique across the whole dependency graph. +links = "risuko-webview-upgrade" + +[dependencies] +tauri = { version = "2" } + +[build-dependencies] +tauri-plugin = { version = "2", features = ["build"] } diff --git a/src-tauri/risuko-webview-upgrade/android/build.gradle.kts b/src-tauri/risuko-webview-upgrade/android/build.gradle.kts new file mode 100644 index 00000000..cf403a93 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/build.gradle.kts @@ -0,0 +1,132 @@ +import groovy.json.JsonSlurper +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "app.risuko.webview_upgrade" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + + sourceSets["main"].kotlin.srcDir( + layout.buildDirectory.dir("generated/source/webview-upgrade") + ) +} + +dependencies { + implementation(project(":tauri-android")) +} + +val defaultMinUpgradeMajor = 106 +val defaultMinSupportedMajor = 75 + +@Suppress("UNCHECKED_CAST") +fun parsePluginBlock(block: Map?): Pair { + if (block == null) return defaultMinUpgradeMajor to defaultMinSupportedMajor + val upgrade = (block["minUpgradeMajor"] as? Number)?.toInt() ?: defaultMinUpgradeMajor + val supported = (block["minSupportedMajor"] as? Number)?.toInt() ?: defaultMinSupportedMajor + require(upgrade > 0) { "minUpgradeMajor must be > 0, got $upgrade" } + require(supported > 0) { "minSupportedMajor must be > 0, got $supported" } + require(supported <= upgrade) { "minSupportedMajor ($supported) must be <= minUpgradeMajor ($upgrade)" } + return upgrade to supported +} + +@Suppress("UNCHECKED_CAST") +fun readFromEnv(jsonText: String?): Pair? { + if (jsonText.isNullOrBlank()) return null + val parsed = try { + JsonSlurper().parseText(jsonText) as? Map + } catch (e: Exception) { + logger.warn("TAURI_WEBVIEW_UPGRADE_PLUGIN_CONFIG parse error: ${e.message}; using defaults") + null + } ?: return null + return parsePluginBlock(parsed) +} + +@Suppress("UNCHECKED_CAST") +fun readFromTauriConf(file: File): Pair? { + val root = JsonSlurper().parse(file) as? Map ?: return null + val plugins = root["plugins"] as? Map ?: return null + val block = plugins["webview-upgrade"] as? Map ?: return null + return parsePluginBlock(block) +} + +fun findTauriConf(start: File): File? { + var current: File? = start + while (current != null) { + val candidate = File(current, "tauri.conf.json") + if (candidate.exists()) return candidate + current = current.parentFile + } + return null +} + +val generatedConfigDir = layout.buildDirectory.dir("generated/source/webview-upgrade") + +val generateWebViewUpgradeConfig = tasks.register("generateWebViewUpgradeConfig") { + val envJson = System.getenv("TAURI_WEBVIEW_UPGRADE_PLUGIN_CONFIG") + val tauriConfFile = findTauriConf(projectDir) + if (envJson.isNullOrBlank() && tauriConfFile != null) { + inputs.file(tauriConfFile) + } + inputs.property("envJson", envJson ?: "") + outputs.dir(generatedConfigDir) + + doLast { + val (minUpgrade, minSupported, source) = when { + !envJson.isNullOrBlank() -> + readFromEnv(envJson)?.let { Triple(it.first, it.second, "TAURI_WEBVIEW_UPGRADE_PLUGIN_CONFIG env var") } + ?: Triple(defaultMinUpgradeMajor, defaultMinSupportedMajor, "defaults (env var unparseable)") + tauriConfFile != null -> + readFromTauriConf(tauriConfFile)?.let { Triple(it.first, it.second, "tauri.conf.json") } + ?: Triple(defaultMinUpgradeMajor, defaultMinSupportedMajor, "defaults (tauri.conf.json had no block)") + else -> Triple(defaultMinUpgradeMajor, defaultMinSupportedMajor, "defaults (no config found)") + } + val outDir = generatedConfigDir.get().asFile + val pkgDir = File(outDir, "app/risuko/webview_upgrade") + pkgDir.mkdirs() + File(pkgDir, "WebViewUpgradeConfig.kt").writeText( + """ + // Generated by build.gradle.kts at build time. Do not edit by hand. + // Source: $source + package app.risuko.webview_upgrade + + internal object WebViewUpgradeConfig { + const val MIN_UPGRADE_MAJOR: Int = $minUpgrade + const val MIN_SUPPORTED_MAJOR: Int = $minSupported + } + """.trimIndent() + "\n" + ) + logger.lifecycle( + "WebViewUpgradeConfig: minUpgradeMajor=$minUpgrade, minSupportedMajor=$minSupported (from $source)" + ) + } +} + +tasks.withType().configureEach { + dependsOn(generateWebViewUpgradeConfig) +} diff --git a/src-tauri/risuko-webview-upgrade/android/consumer-rules.pro b/src-tauri/risuko-webview-upgrade/android/consumer-rules.pro new file mode 100644 index 00000000..4685c808 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/consumer-rules.pro @@ -0,0 +1,3 @@ +# Applied automatically to the host app's R8 step +-keep class app.risuko.webview_upgrade.** { *; } +-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault diff --git a/src-tauri/risuko-webview-upgrade/android/proguard-rules.pro b/src-tauri/risuko-webview-upgrade/android/proguard-rules.pro new file mode 100644 index 00000000..27ad49ea --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/proguard-rules.pro @@ -0,0 +1,2 @@ +-keep class app.risuko.webview_upgrade.** { *; } +-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault diff --git a/src-tauri/risuko-webview-upgrade/android/settings.gradle b/src-tauri/risuko-webview-upgrade/android/settings.gradle new file mode 100644 index 00000000..5b0d3ab5 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/settings.gradle @@ -0,0 +1,30 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + google() + } + resolutionStrategy { + eachPlugin { + switch (requested.id.id) { + case "com.android.library": + useVersion("8.11.0") + break + case "org.jetbrains.kotlin.android": + useVersion("1.9.25") + break + } + } + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + google() + } +} + +include ':tauri-android' +project(':tauri-android').projectDir = new File('./.tauri/tauri-api') diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/AndroidManifest.xml b/src-tauri/risuko-webview-upgrade/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..634eb687 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/BootstrapProvider.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/BootstrapProvider.kt new file mode 100644 index 00000000..fc9b22b1 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/BootstrapProvider.kt @@ -0,0 +1,43 @@ +package app.risuko.webview_upgrade + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.util.Log + +class BootstrapProvider : ContentProvider() { + + override fun onCreate(): Boolean { + val ctx = context + if (ctx == null) { + Log.w(LOG_TAG, "BootstrapProvider has no context; skipping") + return false + } + try { + WebViewUpgradeBootstrap.run(ctx) + } catch (t: Throwable) { + // Never let a startup hook crash the host app. + Log.e(LOG_TAG, "WebView upgrade bootstrap threw", t) + } + return true + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/Common.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/Common.kt new file mode 100644 index 00000000..f97d3e38 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/Common.kt @@ -0,0 +1,43 @@ +package app.risuko.webview_upgrade + +import android.app.ActivityManager +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.Process + +/** Single logcat tag for the whole plugin: `adb logcat -s RWebViewUpgrade`. */ +internal const val LOG_TAG = "RWebViewUpgrade" + +private val mainHandler by lazy { Handler(Looper.getMainLooper()) } + +internal fun runOnMainThread(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block) +} + +internal fun isMainProcess(context: Context): Boolean { + val packageName = context.applicationContext.packageName + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return packageName == Application.getProcessName() + } + val pid = Process.myPid() + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return true + val processes = am.runningAppProcesses ?: return true + for (info in processes) { + if (info.pid == pid) return packageName == info.processName + } + return true +} + +/** Possible webview provider */ +internal val CANDIDATE_PACKAGES = listOf( + "com.google.android.webview", + "com.android.webview", + "com.google.android.webview.beta", + "com.google.android.webview.dev", + "com.google.android.webview.canary", + "com.android.chrome", + "com.huawei.webview", +) diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/HiddenApi.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/HiddenApi.kt new file mode 100644 index 00000000..df1640a6 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/HiddenApi.kt @@ -0,0 +1,42 @@ +package app.risuko.webview_upgrade + +import android.os.Build +import android.util.Log +import java.lang.reflect.Method + +internal object HiddenApi { + + @Volatile + private var done = false + + @Synchronized + fun exempt() { + if (done) return + done = true + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return + try { + val getDeclaredMethod: Method = Class::class.java.getDeclaredMethod( + "getDeclaredMethod", + String::class.java, + arrayOf>()::class.java, + ) + val vmRuntimeClass = Class.forName("dalvik.system.VMRuntime") + + val getRuntime = getDeclaredMethod.invoke( + vmRuntimeClass, "getRuntime", arrayOfNulls>(0), + ) as Method + val setExemptions = getDeclaredMethod.invoke( + vmRuntimeClass, + "setHiddenApiExemptions", + arrayOf>(arrayOf()::class.java), + ) as Method + + val vmRuntime = getRuntime.invoke(null) + // "L" is the JNI signature prefix for every class -> exempt all + setExemptions.invoke(vmRuntime, arrayOf("L")) + Log.i(LOG_TAG, "Hidden API restrictions exempted") + } catch (t: Throwable) { + Log.w(LOG_TAG, "Failed to exempt hidden API restrictions: ${t.message}") + } + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/OutdatedWebViewNotice.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/OutdatedWebViewNotice.kt new file mode 100644 index 00000000..8f03afe0 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/OutdatedWebViewNotice.kt @@ -0,0 +1,81 @@ +package app.risuko.webview_upgrade + +import android.app.Activity +import android.app.AlertDialog +import android.app.Application +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log + +/** + * Native fallback shown when the system WebView is too old to render the UI and + * no newer kernel could be swapped in. + */ +internal object OutdatedWebViewNotice { + + @Volatile + private var scheduled = false + + @Synchronized + fun schedule(context: Context, recommendedPackage: String?) { + if (scheduled) return + val app = context.applicationContext as? Application ?: run { + Log.w(LOG_TAG, "cannot schedule notice: app context is not an Application") + return + } + scheduled = true + app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + override fun onActivityResumed(activity: Activity) { + if (activity.isFinishing) return + app.unregisterActivityLifecycleCallbacks(this) + showDialog(activity, recommendedPackage) + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityStarted(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + }) + } + + private fun showDialog(activity: Activity, recommendedPackage: String?) { + if (activity.isFinishing) return + try { + val message = activity.getString(R.string.webview_upgrade_outdated_message) + val updateLabel = activity.getString(R.string.webview_upgrade_update_button) + val dismissLabel = activity.getString(R.string.webview_upgrade_dismiss_button) + val target = recommendedPackage ?: "com.google.android.webview" + AlertDialog.Builder(activity) + .setCancelable(true) + .setMessage(message) + .setPositiveButton(updateLabel) { _, _ -> openStore(activity, target) } + .setNegativeButton(dismissLabel, null) + .show() + } catch (t: Throwable) { + Log.e(LOG_TAG, "failed to show outdated-WebView notice", t) + } + } + + private fun openStore(activity: Activity, packageName: String) { + val market = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + activity.startActivity(market) + } catch (_: Throwable) { + try { + activity.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$packageName"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } catch (t: Throwable) { + Log.e(LOG_TAG, "no store available to update $packageName", t) + } + } + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/SystemProps.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/SystemProps.kt new file mode 100644 index 00000000..5dba11ca --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/SystemProps.kt @@ -0,0 +1,24 @@ +package app.risuko.webview_upgrade + +import app.risuko.webview_upgrade.reflect.Reflect + +/** + * Reader for `android.os.SystemProperties` (hidden API) + * + * e.g. + * adb shell setprop debug.risuko.wvup.force com.android.chrome # force a swap + * adb shell setprop debug.risuko.wvup.nudge 1 # force the dialog + * adb shell setprop debug.risuko.wvup.minupgrade 999 # raise threshold + * adb shell setprop debug.risuko.wvup.minsupported 999 + * + */ + +internal object SystemProps { + fun get(key: String): String = try { + Reflect.callStatic(Reflect.cls("android.os.SystemProperties"), "get", key) as? String ?: "" + } catch (_: Throwable) { + "" + } + + fun getInt(key: String): Int? = get(key).trim().toIntOrNull() +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/WebViewUpgradeBootstrap.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/WebViewUpgradeBootstrap.kt new file mode 100644 index 00000000..cd98a7b7 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/WebViewUpgradeBootstrap.kt @@ -0,0 +1,134 @@ +package app.risuko.webview_upgrade + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import android.webkit.WebView +import app.risuko.webview_upgrade.swap.WebViewSwap + +internal object WebViewUpgradeBootstrap { + + private const val PROP_FORCE = "debug.risuko.wvup.force" + private const val PROP_NUDGE = "debug.risuko.wvup.nudge" + private const val PROP_MIN_UPGRADE = "debug.risuko.wvup.minupgrade" + private const val PROP_MIN_SUPPORTED = "debug.risuko.wvup.minsupported" + + fun run(context: Context) { + if (!isMainProcess(context)) { + return + } + + try { + val current = currentWebView(context) + val systemMajor = current?.third ?: -1 + + HiddenApi.exempt() + + val minUpgrade = SystemProps.getInt(PROP_MIN_UPGRADE) ?: WebViewUpgradeConfig.MIN_UPGRADE_MAJOR + val minSupported = SystemProps.getInt(PROP_MIN_SUPPORTED) ?: WebViewUpgradeConfig.MIN_SUPPORTED_MAJOR + + val forcePkg = SystemProps.get(PROP_FORCE) + if (forcePkg.isNotEmpty()) { + Log.w(LOG_TAG, "debug force-swap to $forcePkg") + trySwap(context, forcePkg) + return + } + if (SystemProps.get(PROP_NUDGE) == "1") { + Log.w(LOG_TAG, "debug force-nudge") + OutdatedWebViewNotice.schedule(context, bestCandidatePackage(context)) + return + } + + if (systemMajor >= minUpgrade) { + Log.i( + LOG_TAG, + "system WebView: ${current?.first ?: ""} v${current?.second ?: "?"} " + + "(major=$systemMajor); recent enough, skipping", + ) + return + } + + val candidate = bestCandidate(context, systemMajor) + if (candidate != null) { + Log.i(LOG_TAG, "candidate kernel: ${candidate.first} major=${candidate.second}") + if (trySwap(context, candidate.first)) { + if (candidate.second in 0 until minSupported) { + OutdatedWebViewNotice.schedule(context, candidate.first) + } + return + } + } else { + Log.i(LOG_TAG, "no newer installed WebView candidate found") + } + + if (systemMajor in 0 until minSupported) { + OutdatedWebViewNotice.schedule(context, candidate?.first ?: bestCandidatePackage(context)) + } + } catch (t: Throwable) { + Log.e(LOG_TAG, "bootstrap failed", t) + } + } + + /** @return true if the swap was applied. */ + private fun trySwap(context: Context, packageName: String): Boolean { + return try { + WebViewSwap.swapToInstalledPackage(context, packageName) + true + } catch (t: Throwable) { + Log.e(LOG_TAG, "swap to $packageName failed; degrading", t) + false + } + } + + /** Current system WebView as (packageName, versionName, major) or null. */ + private fun currentWebView(context: Context): Triple? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val pi = runCatching { WebView.getCurrentWebViewPackage() }.getOrNull() + if (pi != null) return Triple(pi.packageName, pi.versionName, majorOf(pi.versionName)) + } + // Pre-O fallback: probe known packages. + for (pkg in CANDIDATE_PACKAGES) { + val major = installedMajor(context, pkg) ?: continue + return Triple(pkg, installedVersionName(context, pkg), major) + } + return null + } + + private fun bestCandidate(context: Context, systemMajor: Int): Pair? { + var best: Pair? = null + for (pkg in CANDIDATE_PACKAGES) { + val major = installedMajor(context, pkg) ?: continue + if (major <= systemMajor) continue + if (best == null || major > best.second) best = pkg to major + } + return best + } + + private fun bestCandidatePackage(context: Context): String? { + var best: Pair? = null + for (pkg in CANDIDATE_PACKAGES) { + val major = installedMajor(context, pkg) ?: continue + if (best == null || major > best.second) best = pkg to major + } + return best?.first + } + + private fun installedMajor(context: Context, pkg: String): Int? = + majorOf(installedVersionName(context, pkg)).takeIf { it >= 0 } + + private fun installedVersionName(context: Context, pkg: String): String? = try { + context.packageManager.getPackageInfo(pkg, 0).versionName + } catch (_: PackageManager.NameNotFoundException) { + null + } catch (_: Throwable) { + null + } + + private fun majorOf(versionName: String?): Int { + if (versionName.isNullOrEmpty()) return -1 + val firstDot = versionName.indexOf('.') + val head = if (firstDot >= 0) versionName.substring(0, firstDot) else versionName + return head.trim().toIntOrNull() ?: -1 + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/reflect/Reflect.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/reflect/Reflect.kt new file mode 100644 index 00000000..ead2c597 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/reflect/Reflect.kt @@ -0,0 +1,181 @@ +package app.risuko.webview_upgrade.reflect + +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.lang.reflect.Proxy +import java.util.concurrent.ConcurrentHashMap + +internal object Reflect { + + private val classCache = ConcurrentHashMap>() + private val fieldCache = ConcurrentHashMap() + private val methodCache = ConcurrentHashMap() + + fun cls(name: String): Class<*> = + classCache.getOrPut(name) { Class.forName(name) } + + fun clsOrNull(name: String): Class<*>? = + try { cls(name) } catch (t: Throwable) { null } + + + private fun findField(start: Class<*>, name: String): Field { + val key = "${start.name}#$name" + fieldCache[key]?.let { return it } + var c: Class<*>? = start + while (c != null) { + try { + val f = c.getDeclaredField(name) + f.isAccessible = true + fieldCache[key] = f + return f + } catch (_: NoSuchFieldException) { + c = c.superclass + } + } + throw NoSuchFieldException("$name on ${start.name}") + } + + fun getStaticField(clazz: Class<*>, name: String): Any? = + findField(clazz, name).get(null) + + fun setStaticField(clazz: Class<*>, name: String, value: Any?) { + findField(clazz, name).set(null, value) + } + + fun getField(target: Any, name: String): Any? = + findField(target.javaClass, name).get(target) + + fun setField(target: Any, name: String, value: Any?) { + findField(target.javaClass, name).set(target, value) + } + + fun setFieldOn(declaringClass: Class<*>, target: Any?, name: String, value: Any?) { + findField(declaringClass, name).set(target, value) + } + + private fun argTypeKey(args: Array): String = + args.joinToString(",") { it?.javaClass?.name ?: "null" } + + private fun findMethod(start: Class<*>, name: String, args: Array): Method { + val key = "${start.name}#$name(${argTypeKey(args)})" + methodCache[key]?.let { return it } + var best: Method? = null + var bestScore = Int.MIN_VALUE + var c: Class<*>? = start + val seen = HashSet() + while (c != null) { + for (m in c.declaredMethods) { + if (m.name != name) continue + val sig = m.parameterTypes.joinToString(",") { it.name } + if (!seen.add("$name($sig)")) continue + val score = scoreParams(m.parameterTypes, args) ?: continue + if (score > bestScore) { + bestScore = score + best = m + } + } + c = c.superclass + } + val chosen = best ?: throw NoSuchMethodException("$name on ${start.name} for ${argTypeKey(args)}") + chosen.isAccessible = true + methodCache[key] = chosen + return chosen + } + + private fun scoreParams(types: Array>, args: Array): Int? { + if (types.size != args.size) return null + var score = 0 + for (i in types.indices) { + val t = types[i] + val a = args[i] + if (a == null) { + if (t.isPrimitive) return null + continue + } + val ac = a.javaClass + when { + t == ac -> score += 4 + t.isAssignableFrom(ac) -> score += 3 + t.isPrimitive && isBoxedMatch(t, ac) -> score += 4 + else -> return null + } + } + return score + } + + private fun isBoxedMatch(prim: Class<*>, boxed: Class<*>): Boolean = when (prim) { + Integer.TYPE -> boxed == Integer::class.javaObjectType + java.lang.Long.TYPE -> boxed == java.lang.Long::class.javaObjectType + java.lang.Boolean.TYPE -> boxed == java.lang.Boolean::class.javaObjectType + java.lang.Short.TYPE -> boxed == java.lang.Short::class.javaObjectType + java.lang.Byte.TYPE -> boxed == java.lang.Byte::class.javaObjectType + Character.TYPE -> boxed == Character::class.javaObjectType + java.lang.Float.TYPE -> boxed == java.lang.Float::class.javaObjectType + java.lang.Double.TYPE -> boxed == java.lang.Double::class.javaObjectType + else -> false + } + + fun callStatic(clazz: Class<*>, name: String, vararg args: Any?): Any? { + val m = findMethod(clazz, name, args) + require(Modifier.isStatic(m.modifiers)) { "$name on ${clazz.name} is not static" } + return m.invoke(null, *args) + } + + fun call(target: Any, name: String, vararg args: Any?): Any? = + findMethod(target.javaClass, name, args).invoke(target, *args) + + fun callStaticTyped( + clazz: Class<*>, + name: String, + paramTypes: Array>, + args: Array, + ): Any? { + val m = clazz.getDeclaredMethod(name, *paramTypes) + m.isAccessible = true + return m.invoke(null, *args) + } + + fun newInterfaceProxy( + target: Any, + interceptors: Map, + ): Any { + val interfaces = collectInterfaces(target.javaClass) + require(interfaces.isNotEmpty()) { "${target.javaClass} implements no interfaces" } + val loader = interfaces[0].classLoader ?: target.javaClass.classLoader + return Proxy.newProxyInstance(loader, interfaces) { proxy, method, rawArgs -> + val args = rawArgs ?: emptyArray() + when (method.name) { + "hashCode" -> return@newProxyInstance System.identityHashCode(proxy) + "equals" -> return@newProxyInstance proxy === args.getOrNull(0) + "toString" -> return@newProxyInstance "Proxy(${target.javaClass.name})" + } + val interceptor = interceptors[method.name] + if (interceptor != null) { + interceptor.invoke(args) { method.invoke(target, *args) } + } else { + method.invoke(target, *args) + } + } + } + + private fun collectInterfaces(start: Class<*>): Array> { + val out = LinkedHashSet>() + var c: Class<*>? = start + while (c != null && c != Any::class.java) { + for (i in c.interfaces) addInterface(i, out) + c = c.superclass + } + return out.toTypedArray() + } + + private fun addInterface(i: Class<*>, out: LinkedHashSet>) { + if (out.add(i)) { + for (p in i.interfaces) addInterface(p, out) + } + } + + fun interface Interceptor { + fun invoke(args: Array, original: () -> Any?): Any? + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/SandboxExtras.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/SandboxExtras.kt new file mode 100644 index 00000000..3fcf5ab7 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/SandboxExtras.kt @@ -0,0 +1,14 @@ +package app.risuko.webview_upgrade.sandbox + +/** Intent-extra keys the [ActivityManagerHook] sets and the delegate reads */ +internal object SandboxExtras { + const val WEBVIEW_APK_PATH = "EXTRA_WEBVIEW_APK_PATH" + const val WEBVIEW_LIBS_PATH = "EXTRA_WEBVIEW_LIBS_PATH" + const val WEBVIEW_PACKAGE_NAME = "EXTRA_WEBVIEW_PACKAGE_NAME" + const val ORIGINAL_SERVICE_CLASS = "EXTRA_ORIGINAL_SERVICE_CLASS" + const val WEBVIEW_SHARED_LIBS = "EXTRA_WEBVIEW_SHARED_LIBS" + + /** Chromium's own browser-package extra */ + const val CHROMIUM_BROWSER_PACKAGE = + "org.chromium.base.process_launcher.extra.browser_package_name" +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/SandboxedProcessServiceDelegate.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/SandboxedProcessServiceDelegate.kt new file mode 100644 index 00000000..a9652ea8 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/SandboxedProcessServiceDelegate.kt @@ -0,0 +1,432 @@ +package app.risuko.webview_upgrade.sandbox + +import android.app.Service +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.res.AssetManager +import android.content.res.Resources +import android.os.Build +import android.os.IBinder +import android.os.Process +import android.util.Log +import app.risuko.webview_upgrade.LOG_TAG +import app.risuko.webview_upgrade.swap.Framework +import app.risuko.webview_upgrade.swap.PackageManagerHook +import dalvik.system.PathClassLoader +import java.io.File +import java.lang.ref.WeakReference +import java.lang.reflect.Array as ReflectArray + +internal class SandboxedProcessServiceDelegate { + + private var hostService: Service? = null + private var appContext: Context? = null + + private var realService: Service? = null + private var onBindMethod: java.lang.reflect.Method? = null + private var onDestroyMethod: java.lang.reflect.Method? = null + private var onRebindMethod: java.lang.reflect.Method? = null + private var onUnbindMethod: java.lang.reflect.Method? = null + + private var dexInjected = false + private var ready = false + + fun onCreate(hostService: Service, appContext: Context) { + this.hostService = hostService + this.appContext = appContext + } + + fun onBind(intent: Intent): IBinder? { + ensureReady(intent) + val real = realService + val bind = onBindMethod + if (!ready || real == null || bind == null) { + Log.e(LOG_TAG, "sandbox onBind: real service not ready") + return null + } + return try { + bind.invoke(real, intent) as? IBinder + } catch (t: Throwable) { + Log.e(LOG_TAG, "sandbox onBind delegate failed", t) + null + } + } + + fun onDestroy() { + if (!ready) return + val real = realService ?: return + runCatching { onDestroyMethod?.invoke(real) } + } + + fun onRebind(intent: Intent) { + if (!ready) return + val real = realService ?: return + runCatching { onRebindMethod?.invoke(real, intent) } + } + + fun onUnbind(intent: Intent): Boolean { + if (!ready) return false + val real = realService ?: return false + return runCatching { onUnbindMethod?.invoke(real, intent) as? Boolean ?: false }.getOrDefault(false) + } + + @Synchronized + private fun ensureReady(intent: Intent) { + if (ready) return + val app = appContext ?: return + + val apkPath = intent.getStringExtra(SandboxExtras.WEBVIEW_APK_PATH) + if (apkPath == null) { + Log.e(LOG_TAG, "sandbox: missing APK path extra") + return + } + var libsPath = intent.getStringExtra(SandboxExtras.WEBVIEW_LIBS_PATH) + val sharedLibs = intent.getStringArrayExtra(SandboxExtras.WEBVIEW_SHARED_LIBS) + var realClassName = intent.getStringExtra(SandboxExtras.ORIGINAL_SERVICE_CLASS) + if (realClassName.isNullOrEmpty()) { + realClassName = "org.chromium.content.app.SandboxedProcessService0" + } + + var webViewPkg = intent.getStringExtra(SandboxExtras.WEBVIEW_PACKAGE_NAME) + if (webViewPkg.isNullOrEmpty()) { + webViewPkg = intent.getStringExtra(SandboxExtras.CHROMIUM_BROWSER_PACKAGE) + } + if (webViewPkg.isNullOrEmpty()) { + Log.e(LOG_TAG, "sandbox: missing WebView package name") + return + } + + if (libsPath == null) { + libsPath = deriveLibsRootFromInstalledPkg(webViewPkg) + } + val nativeLibDir = resolveNativeLibraryDir(libsPath) + + installSandboxPmsHookOnce(app, webViewPkg, apkPath, libsPath) + patchApplicationInfo(apkPath, nativeLibDir) + + val webViewCtx = tryCreateWebViewPackageContext(webViewPkg) + + if (webViewCtx != null && installChromiumDelegatingClassLoader(webViewCtx.classLoader)) { + if (!dexInjected) dexInjected = injectDex(apkPath, nativeLibDir, sharedLibs, false) + if (!dexInjected) { + Log.e(LOG_TAG, "dex injection failed (delegating CL path)") + return + } + val attachCtx = SandboxedWebViewServiceContext(webViewCtx, app) + ready = initRealService(realClassName, attachCtx, useAppClassLoader = false) + if (ready) return + ready = initRealService(realClassName, attachCtx, useAppClassLoader = true) + if (ready) return + } + + if (!dexInjected) dexInjected = injectDex(apkPath, nativeLibDir, sharedLibs, true) + if (!dexInjected) { + Log.e(LOG_TAG, "dex injection failed") + return + } + if (webViewCtx != null) { + val attachCtx = SandboxedWebViewServiceContext(webViewCtx, app) + ready = initRealService(realClassName, attachCtx, useAppClassLoader = true) + if (ready) return + } + val base = hostService?.baseContext ?: return + ready = initRealService(realClassName, base, useAppClassLoader = false) + } + + private fun tryCreateWebViewPackageContext(webViewPkg: String): Context? { + Framework.invalidateLoadedApkCache(webViewPkg) + return try { + appContext?.createPackageContext( + webViewPkg, + Context.CONTEXT_INCLUDE_CODE or Context.CONTEXT_IGNORE_SECURITY, + ) + } catch (t: Throwable) { + Log.e(LOG_TAG, "createPackageContext failed: $webViewPkg", t) + null + } + } + + private fun deriveLibsRootFromInstalledPkg(pkgName: String): String? { + return try { + val ai = appContext?.packageManager?.getApplicationInfo(pkgName, 0) ?: return null + runCatching { + val f = android.content.pm.ApplicationInfo::class.java.getDeclaredField("nativeLibraryRootDir") + f.isAccessible = true + (f.get(ai) as? String)?.takeIf { it.isNotEmpty() } + }.getOrNull()?.let { return it } + ai.nativeLibraryDir?.takeIf { it.isNotEmpty() }?.let { File(it).parentFile?.absolutePath } + } catch (t: Throwable) { + Log.w(LOG_TAG, "deriveLibsRootFromInstalledPkg failed: ${t.message}") + null + } + } + + private fun resolveNativeLibraryDir(libsRoot: String?): String? { + if (libsRoot == null) return null + val dir = File(libsRoot) + if (!dir.isDirectory) return libsRoot + val names = dir.list() + if (names != null && names.any { it.endsWith(".so") }) return dir.absolutePath + val is64 = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) Process.is64Bit() else false + val abis = (if (is64) Build.SUPPORTED_64_BIT_ABIS else Build.SUPPORTED_32_BIT_ABIS).clone() + abis.sort() + if (names != null) { + for (name in names) { + if (java.util.Arrays.binarySearch(abis, name) >= 0) return File(dir, name).absolutePath + } + } + return dir.absolutePath + } + + private fun patchApplicationInfo(apkPath: String, nativeLibraryDir: String?) { + try { + val appInfo = appContext?.applicationInfo ?: return + appInfo.sourceDir = apkPath + appInfo.publicSourceDir = apkPath + if (nativeLibraryDir != null) appInfo.nativeLibraryDir = nativeLibraryDir + } catch (t: Throwable) { + Log.e(LOG_TAG, "patchApplicationInfo failed", t) + } + } + + private fun installChromiumDelegatingClassLoader(webViewClassLoader: ClassLoader): Boolean { + return try { + val app = appContext ?: return false + val atClass = Class.forName("android.app.ActivityThread") + val at = atClass.getMethod("currentActivityThread").invoke(null) + val mPackagesField = atClass.getDeclaredField("mPackages").apply { isAccessible = true } + @Suppress("UNCHECKED_CAST") + val mPackages = mPackagesField.get(at) as? Map> + val pkg = app.packageName + val ref = mPackages?.get(pkg) ?: run { + Log.e(LOG_TAG, "delegating CL: no host LoadedApk for $pkg") + return false + } + val loadedApk = ref.get() ?: run { + Log.e(LOG_TAG, "delegating CL: LoadedApk ref empty") + return false + } + val loadedApkClass = Class.forName("android.app.LoadedApk") + val mClassLoaderField = loadedApkClass.getDeclaredField("mClassLoader").apply { isAccessible = true } + var appCl = mClassLoaderField.get(loadedApk) as? ClassLoader + if (appCl == null) { + val getCl = loadedApkClass.getDeclaredMethod("getClassLoader").apply { isAccessible = true } + appCl = getCl.invoke(loadedApk) as? ClassLoader + } + if (appCl is ChromiumDelegatingClassLoader) return true + if (appCl == null) return false + val composite = ChromiumDelegatingClassLoader(appCl, webViewClassLoader) + mClassLoaderField.set(loadedApk, composite) + patchContextImplClassLoaderField(app, composite) + hostService?.let { patchContextImplClassLoaderField(it, composite) } + true + } catch (t: Throwable) { + Log.e(LOG_TAG, "installChromiumDelegatingClassLoader failed", t) + false + } + } + + private fun patchContextImplClassLoaderField(ctx: Context, cl: ClassLoader) { + var base: Context = ctx + while (base is ContextWrapper) base = base.baseContext + val implClass = Class.forName("android.app.ContextImpl") + if (!implClass.isInstance(base)) return + val f = implClass.getDeclaredField("mClassLoader").apply { isAccessible = true } + f.set(base, cl) + } + + private fun injectDex( + apkPath: String, + nativeLibDir: String?, + sharedLibs: Array?, + mergeWebViewApkIntoHost: Boolean, + ): Boolean { + return try { + if (!mergeWebViewApkIntoHost && (sharedLibs == null || sharedLibs.isEmpty())) { + return true + } + val currentCl = appContext?.classLoader ?: return false + val dexPathListClass = Class.forName("dalvik.system.DexPathList") + val dexElementsField = dexPathListClass.getDeclaredField("dexElements").apply { isAccessible = true } + val nativeLibPathElementsField = runCatching { + dexPathListClass.getDeclaredField("nativeLibraryPathElements").apply { isAccessible = true } + }.getOrNull() + val pathListField = Class.forName("dalvik.system.BaseDexClassLoader") + .getDeclaredField("pathList").apply { isAccessible = true } + + var webviewDexElements = arrayOfNulls(0) + var webviewNativeElements = arrayOfNulls(0) + if (mergeWebViewApkIntoHost) { + val webviewLoader = PathClassLoader(apkPath, nativeLibDir, currentCl.parent) + val webviewPathList = pathListField.get(webviewLoader) + webviewDexElements = dexElementsField.get(webviewPathList) as Array + if (nativeLibPathElementsField != null) { + (nativeLibPathElementsField.get(webviewPathList) as? Array)?.let { webviewNativeElements = it } + } + } + + var sharedDexElements = arrayOfNulls(0) + var sharedNativeElements = arrayOfNulls(0) + if (sharedLibs != null && sharedLibs.isNotEmpty()) { + val sharedPath = sharedLibs.joinToString(File.pathSeparator) + val sharedLoader = PathClassLoader(sharedPath, null, currentCl.parent) + val sharedPathList = pathListField.get(sharedLoader) + (dexElementsField.get(sharedPathList) as? Array)?.let { sharedDexElements = it } + if (nativeLibPathElementsField != null) { + (nativeLibPathElementsField.get(sharedPathList) as? Array)?.let { sharedNativeElements = it } + } + } + + val currentPathList = pathListField.get(currentCl) + val oldDexElements = dexElementsField.get(currentPathList) as? Array + val oldLen = oldDexElements?.size ?: 0 + val webLen = webviewDexElements.size + val sharedLen = sharedDexElements.size + if (webLen == 0 && sharedLen == 0) { + Log.e(LOG_TAG, "no dex elements extracted") + return false + } + val componentType = (oldDexElements ?: webviewDexElements).javaClass.componentType!! + @Suppress("UNCHECKED_CAST") + val combined = ReflectArray.newInstance(componentType, webLen + sharedLen + oldLen) as Array + if (webLen > 0) System.arraycopy(webviewDexElements, 0, combined, 0, webLen) + if (sharedLen > 0) System.arraycopy(sharedDexElements, 0, combined, webLen, sharedLen) + if (oldLen > 0) System.arraycopy(oldDexElements!!, 0, combined, webLen + sharedLen, oldLen) + dexElementsField.set(currentPathList, combined) + + if (nativeLibPathElementsField != null) { + val oldNative = nativeLibPathElementsField.get(currentPathList) as? Array + val oldNativeLen = oldNative?.size ?: 0 + val webNativeLen = webviewNativeElements.size + val sharedNativeLen = sharedNativeElements.size + if (webNativeLen > 0 || sharedNativeLen > 0) { + val nativeComponentType = when { + webNativeLen > 0 -> webviewNativeElements.javaClass.componentType!! + oldNativeLen > 0 -> oldNative!!.javaClass.componentType!! + else -> sharedNativeElements.javaClass.componentType!! + } + @Suppress("UNCHECKED_CAST") + val combinedNative = ReflectArray.newInstance( + nativeComponentType, webNativeLen + sharedNativeLen + oldNativeLen, + ) as Array + if (webNativeLen > 0) System.arraycopy(webviewNativeElements, 0, combinedNative, 0, webNativeLen) + if (sharedNativeLen > 0) System.arraycopy(sharedNativeElements, 0, combinedNative, webNativeLen, sharedNativeLen) + if (oldNativeLen > 0) System.arraycopy(oldNative!!, 0, combinedNative, webNativeLen + sharedNativeLen, oldNativeLen) + nativeLibPathElementsField.set(currentPathList, combinedNative) + } + } + true + } catch (t: Throwable) { + Log.e(LOG_TAG, "dex injection failed", t) + false + } + } + + private fun initRealService( + realClassName: String, + contextForAttach: Context, + useAppClassLoader: Boolean, + ): Boolean { + return try { + val cl = if (useAppClassLoader && appContext != null) { + appContext!!.classLoader + } else { + contextForAttach.classLoader + } + val realClass = cl.loadClass(realClassName) + val real = realClass.newInstance() as Service + realService = real + + val attachBaseContext = ContextWrapper::class.java + .getDeclaredMethod("attachBaseContext", Context::class.java).apply { isAccessible = true } + attachBaseContext.invoke(real, contextForAttach) + + onBindMethod = realClass.getMethod("onBind", Intent::class.java).apply { isAccessible = true } + onDestroyMethod = runCatching { realClass.getMethod("onDestroy").apply { isAccessible = true } }.getOrNull() + onRebindMethod = runCatching { + realClass.getMethod("onRebind", Intent::class.java).apply { isAccessible = true } + }.getOrNull() + onUnbindMethod = runCatching { + realClass.getMethod("onUnbind", Intent::class.java).apply { isAccessible = true } + }.getOrNull() + + realClass.getMethod("onCreate").apply { isAccessible = true }.invoke(real) + Log.i(LOG_TAG, "real sandbox service onCreate ok: $realClassName") + true + } catch (t: Throwable) { + Log.e(LOG_TAG, "real sandbox service init failed: ${t.message}") + false + } + } + + /** `org.chromium.*` resolves against the WebView kernel; everything else via the host CL */ + private class ChromiumDelegatingClassLoader( + private val appClassLoader: ClassLoader, + private val chromiumClassLoader: ClassLoader, + ) : ClassLoader(appClassLoader.parent) { + override fun loadClass(name: String, resolve: Boolean): Class<*> { + synchronized(this) { + var c = findLoadedClass(name) + if (c == null) { + c = if (name.startsWith("org.chromium.")) { + chromiumClassLoader.loadClass(name) + } else { + appClassLoader.loadClass(name) + } + } + if (resolve) resolveClass(c) + return c + } + } + } + + /** Keeps the kernel's resources but routes getApplicationContext to a host-backed proxy */ + private class SandboxedWebViewServiceContext( + webViewPackageContext: Context, + hostAppContext: Context, + ) : ContextWrapper(webViewPackageContext) { + private val appContextProxy: Context = + SandboxedApplicationContext(hostAppContext, webViewPackageContext) + + override fun getApplicationContext(): Context = appContextProxy + } + + /** Host application context whose assets/resources come from the WebView kernel APK */ + private class SandboxedApplicationContext( + hostAppContext: Context, + private val webViewContext: Context, + ) : ContextWrapper(hostAppContext) { + private val realHostApplicationContext: Context = + hostAppContext.applicationContext ?: hostAppContext + + override fun getAssets(): AssetManager = webViewContext.assets + override fun getResources(): Resources = webViewContext.resources + override fun getApplicationContext(): Context = realHostApplicationContext + } + + companion object { + private val pmsLock = Any() + + @Volatile + private var sandboxPmsInstalled = false + + private fun installSandboxPmsHookOnce( + appContext: Context, + webViewPkg: String, + apkPath: String, + libsPath: String?, + ) { + synchronized(pmsLock) { + if (sandboxPmsInstalled) return + if (libsPath == null) { + Log.i(LOG_TAG, "sandbox: libsPath null, skipping PMS hook (system handles installed pkg)") + return + } + PackageManagerHook(appContext, webViewPkg, apkPath, libsPath).hook() + sandboxPmsInstalled = true + Log.i(LOG_TAG, "sandbox PMS hook installed for $webViewPkg") + } + } + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService0.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService0.kt new file mode 100644 index 00000000..1229413d --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService0.kt @@ -0,0 +1,33 @@ +package app.risuko.webview_upgrade.sandbox + +import android.app.Service +import android.content.Intent +import android.os.IBinder + +/** + * Stand-in renderer service (slot 0). Runs in its own normal child process and + * delegates everything to [SandboxedProcessServiceDelegate], which loads and + * drives the swapped WebView kernel's real sandboxed service. The + * [android.os.IBinder] hand-off is what links Chromium's browser process to + * this renderer + */ +class StubSandboxedProcessService0 : Service() { + private val delegate = SandboxedProcessServiceDelegate() + override fun onCreate() { + super.onCreate() + delegate.onCreate(this, applicationContext) + } + override fun onBind(intent: Intent): IBinder? = delegate.onBind(intent) + override fun onRebind(intent: Intent) { + super.onRebind(intent) + delegate.onRebind(intent) + } + override fun onUnbind(intent: Intent): Boolean { + val result = super.onUnbind(intent) + return delegate.onUnbind(intent) || result + } + override fun onDestroy() { + super.onDestroy() + delegate.onDestroy() + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService1.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService1.kt new file mode 100644 index 00000000..8d1d9c76 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService1.kt @@ -0,0 +1,27 @@ +package app.risuko.webview_upgrade.sandbox + +import android.app.Service +import android.content.Intent +import android.os.IBinder + +/** Stand-in renderer service (slot 1). See [StubSandboxedProcessService0] */ +class StubSandboxedProcessService1 : Service() { + private val delegate = SandboxedProcessServiceDelegate() + override fun onCreate() { + super.onCreate() + delegate.onCreate(this, applicationContext) + } + override fun onBind(intent: Intent): IBinder? = delegate.onBind(intent) + override fun onRebind(intent: Intent) { + super.onRebind(intent) + delegate.onRebind(intent) + } + override fun onUnbind(intent: Intent): Boolean { + val result = super.onUnbind(intent) + return delegate.onUnbind(intent) || result + } + override fun onDestroy() { + super.onDestroy() + delegate.onDestroy() + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService2.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService2.kt new file mode 100644 index 00000000..a01e0db4 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService2.kt @@ -0,0 +1,27 @@ +package app.risuko.webview_upgrade.sandbox + +import android.app.Service +import android.content.Intent +import android.os.IBinder + +/** Stand-in renderer service (slot 2). See [StubSandboxedProcessService0] */ +class StubSandboxedProcessService2 : Service() { + private val delegate = SandboxedProcessServiceDelegate() + override fun onCreate() { + super.onCreate() + delegate.onCreate(this, applicationContext) + } + override fun onBind(intent: Intent): IBinder? = delegate.onBind(intent) + override fun onRebind(intent: Intent) { + super.onRebind(intent) + delegate.onRebind(intent) + } + override fun onUnbind(intent: Intent): Boolean { + val result = super.onUnbind(intent) + return delegate.onUnbind(intent) || result + } + override fun onDestroy() { + super.onDestroy() + delegate.onDestroy() + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService3.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService3.kt new file mode 100644 index 00000000..c8339db9 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService3.kt @@ -0,0 +1,27 @@ +package app.risuko.webview_upgrade.sandbox + +import android.app.Service +import android.content.Intent +import android.os.IBinder + +/** Stand-in renderer service (slot 3). See [StubSandboxedProcessService0] */ +class StubSandboxedProcessService3 : Service() { + private val delegate = SandboxedProcessServiceDelegate() + override fun onCreate() { + super.onCreate() + delegate.onCreate(this, applicationContext) + } + override fun onBind(intent: Intent): IBinder? = delegate.onBind(intent) + override fun onRebind(intent: Intent) { + super.onRebind(intent) + delegate.onRebind(intent) + } + override fun onUnbind(intent: Intent): Boolean { + val result = super.onUnbind(intent) + return delegate.onUnbind(intent) || result + } + override fun onDestroy() { + super.onDestroy() + delegate.onDestroy() + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService4.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService4.kt new file mode 100644 index 00000000..2f9e0cff --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/sandbox/StubSandboxedProcessService4.kt @@ -0,0 +1,27 @@ +package app.risuko.webview_upgrade.sandbox + +import android.app.Service +import android.content.Intent +import android.os.IBinder + +/** Stand-in renderer service (slot 4). See [StubSandboxedProcessService0] */ +class StubSandboxedProcessService4 : Service() { + private val delegate = SandboxedProcessServiceDelegate() + override fun onCreate() { + super.onCreate() + delegate.onCreate(this, applicationContext) + } + override fun onBind(intent: Intent): IBinder? = delegate.onBind(intent) + override fun onRebind(intent: Intent) { + super.onRebind(intent) + delegate.onRebind(intent) + } + override fun onUnbind(intent: Intent): Boolean { + val result = super.onUnbind(intent) + return delegate.onUnbind(intent) || result + } + override fun onDestroy() { + super.onDestroy() + delegate.onDestroy() + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/ActivityManagerHook.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/ActivityManagerHook.kt new file mode 100644 index 00000000..95755b04 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/ActivityManagerHook.kt @@ -0,0 +1,170 @@ +package app.risuko.webview_upgrade.swap + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.util.Log +import app.risuko.webview_upgrade.LOG_TAG +import app.risuko.webview_upgrade.reflect.Reflect +import app.risuko.webview_upgrade.sandbox.SandboxExtras +import java.util.regex.Pattern + +/** + * Reroutes Chromium's multi-process renderer binds onto stub services. + */ +internal class ActivityManagerHook( + private val context: Context, + private val targetWebViewPackage: String, + private val webViewApkPath: String?, + private val webViewLibsPath: String?, +) { + private val hostPackageName: String = context.packageName + private val maxStubServices: Int = resolveMaxStubServices(context) + + private var singleton: Any? = null + private var originalAm: Any? = null + private var hooked = false + + fun hook() { + if (hooked) return + val gDefault = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Reflect.getStaticField(Reflect.cls("android.app.ActivityManager"), "IActivityManagerSingleton") + } else { + Reflect.getStaticField(Reflect.cls("android.app.ActivityManagerNative"), "gDefault") + } ?: run { + Log.e(LOG_TAG, "ActivityManager singleton is null; cannot hook") + return + } + singleton = gDefault + val original = Reflect.getField(gDefault, "mInstance") ?: run { + Log.e(LOG_TAG, "IActivityManager instance is null; cannot hook") + return + } + originalAm = original + + val interceptors = mapOf( + "bindIsolatedService" to Reflect.Interceptor { args, original2 -> dispatchBind(args, original2) }, + "bindServiceInstance" to Reflect.Interceptor { args, original2 -> dispatchBind(args, original2) }, + ) + val proxyAm = Reflect.newInterfaceProxy(original, interceptors) + Reflect.setField(gDefault, "mInstance", proxyAm) + hooked = true + Log.i(LOG_TAG, "ActivityManagerHook active (host=$hostPackageName, target=$targetWebViewPackage)") + } + + fun restore() { + val s = singleton + val o = originalAm + if (hooked && s != null && o != null) { + runCatching { Reflect.setField(s, "mInstance", o) } + hooked = false + } + } + + private fun dispatchBind(args: Array, original: () -> Any?): Any? { + val redirected = args.filterIsInstance().firstOrNull { redirectSandboxServiceIfNeeded(it) } + return if (redirected != null) callBindServiceOnRealAM(args, original) else original() + } + + private fun redirectSandboxServiceIfNeeded(intent: Intent): Boolean { + val component = intent.component ?: return false + + if (component.packageName == hostPackageName && + component.className?.startsWith(STUB_SERVICE_CLASS_PREFIX) == true + ) { + ensureSandboxIntentExtras(intent) + return true + } + + if (component.packageName != targetWebViewPackage) return false + val className = component.className ?: return false + if (!className.contains(SANDBOX_SERVICE_NAME)) return false + + var sandboxIndex = 0 + val matcher = SANDBOX_SERVICE_INDEX_PATTERN.matcher(className) + if (matcher.find()) { + sandboxIndex = matcher.group(1)?.toIntOrNull() ?: 0 + } + val stubIndex = if (maxStubServices > 0) sandboxIndex % maxStubServices else 0 + val stubComponent = ComponentName(hostPackageName, STUB_SERVICE_CLASS_PREFIX + stubIndex) + + val extras = intent.extras ?: Bundle() + extras.putString(SandboxExtras.WEBVIEW_APK_PATH, webViewApkPath) + extras.putString(SandboxExtras.WEBVIEW_LIBS_PATH, webViewLibsPath) + extras.putString(SandboxExtras.WEBVIEW_PACKAGE_NAME, targetWebViewPackage) + extras.putString(SandboxExtras.ORIGINAL_SERVICE_CLASS, className) + putSharedLibs(extras) + intent.putExtras(extras) + + Log.i(LOG_TAG, "sandbox redirect: ${component.flattenToShortString()} -> ${stubComponent.flattenToShortString()}") + intent.component = stubComponent + return true + } + + private fun ensureSandboxIntentExtras(intent: Intent) { + val extras = intent.extras ?: Bundle().also { intent.putExtras(it) } + webViewApkPath?.let { extras.putString(SandboxExtras.WEBVIEW_APK_PATH, it) } + webViewLibsPath?.let { extras.putString(SandboxExtras.WEBVIEW_LIBS_PATH, it) } + extras.putString(SandboxExtras.WEBVIEW_PACKAGE_NAME, targetWebViewPackage) + putSharedLibs(extras) + intent.putExtras(extras) + } + + private fun putSharedLibs(extras: Bundle) { + try { + val sharedLibs = WebViewSwap.replaceWebViewPackageInfo + ?.applicationInfo?.sharedLibraryFiles + if (sharedLibs != null) extras.putStringArray(SandboxExtras.WEBVIEW_SHARED_LIBS, sharedLibs) + } catch (t: Throwable) { + Log.e(LOG_TAG, "failed to read WebView sharedLibs: ${t.message}") + } + } + + private fun callBindServiceOnRealAM(originalArgs: Array, fallback: () -> Any?): Any? { + val am = originalAm ?: return fallback() + return try { + val bindServiceMethod = BindServiceArgsAdapter.findBestBindServiceMethod(am, originalArgs) + if (bindServiceMethod == null) { + Log.e(LOG_TAG, "no bindService overload found on AMS") + return fallback() + } + val adapted = BindServiceArgsAdapter.adapt(originalArgs, bindServiceMethod, hostPackageName) + bindServiceMethod.invoke(am, *adapted) + } catch (t: Throwable) { + Log.e(LOG_TAG, "forwarding bindService failed", t) + fallback() + } + } + + companion object { + private const val SANDBOX_SERVICE_NAME = "SandboxedProcessService" + private val SANDBOX_SERVICE_INDEX_PATTERN = Pattern.compile("$SANDBOX_SERVICE_NAME(\\d+)$") + private const val STUB_SERVICE_CLASS_PREFIX = + "app.risuko.webview_upgrade.sandbox.Stub$SANDBOX_SERVICE_NAME" + private const val FALLBACK_STUB_COUNT = 5 + + private fun resolveMaxStubServices(context: Context): Int = try { + val pi = context.packageManager.getPackageInfo( + context.packageName, PackageManager.GET_SERVICES, + ) + val services = pi.services + if (services == null) { + FALLBACK_STUB_COUNT + } else { + val num = Pattern.compile("StubSandboxedProcessService(\\d+)$") + var maxIndex = -1 + for (s in services) { + val name = s.name ?: continue + val m = num.matcher(name) + if (m.find()) maxIndex = maxOf(maxIndex, m.group(1)?.toIntOrNull() ?: -1) + } + if (maxIndex >= 0) maxIndex + 1 else FALLBACK_STUB_COUNT + } + } catch (_: Throwable) { + FALLBACK_STUB_COUNT + } + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/BindServiceArgsAdapter.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/BindServiceArgsAdapter.kt new file mode 100644 index 00000000..795eb7ea --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/BindServiceArgsAdapter.kt @@ -0,0 +1,197 @@ +package app.risuko.webview_upgrade.swap + +import android.content.Context +import android.content.Intent +import java.lang.reflect.Method + +internal object BindServiceArgsAdapter { + + fun findBestBindServiceMethod(originalAm: Any, srcArgs: Array?): Method? { + val srcLen = srcArgs?.size ?: 0 + val candidates = ArrayList() + try { + val amClass = originalAm.javaClass + for (iface in amClass.interfaces) { + for (m in iface.declaredMethods) { + if (m.name == "bindService") candidates.add(m) + } + } + if (candidates.isEmpty()) { + for (m in amClass.methods) { + if (m.name == "bindService") candidates.add(m) + } + } + } catch (_: Throwable) { + return null + } + if (candidates.isEmpty()) return null + + var best: Method? = null + var bestScore = Int.MIN_VALUE + for (m in candidates) { + val pc = m.parameterCount + if (pc > srcLen) continue + val gap = srcLen - pc + if (gap > 3) continue + var score = scorePrefixMatch(m.parameterTypes, srcArgs) + if (gap <= 2) score += 20 + if (gap == 0) score += 5 + if (score > bestScore) { + bestScore = score + best = m + } + } + return best + } + + private fun scorePrefixMatch(targetTypes: Array>, srcArgs: Array?): Int { + if (srcArgs == null) return 0 + var score = 0 + val n = minOf(targetTypes.size, srcArgs.size) + for (i in 0 until n) { + val arg = srcArgs[i] ?: continue + val t = targetTypes[i] + val ac = arg.javaClass + when { + t.isAssignableFrom(ac) -> score += 4 + t.isPrimitive -> when { + t == Integer.TYPE && arg is Int -> score += 4 + t == java.lang.Long.TYPE && arg is Long -> score += 4 + t == java.lang.Boolean.TYPE && arg is Boolean -> score += 4 + } + (t == Integer::class.javaObjectType || t == java.lang.Long::class.javaObjectType) && arg is Number -> + score += 2 + } + } + return score + } + + fun adapt(srcArgs: Array?, bindServiceMethod: Method, hostPackageName: String?): Array { + val targetParamCount = bindServiceMethod.parameterCount + val targetTypes = bindServiceMethod.parameterTypes + val mutable = ArrayList() + if (srcArgs != null) mutable.addAll(srcArgs) + + if (!trimToTargetCount(mutable, targetParamCount, hostPackageName)) { + legacyTrimToTargetCount(mutable, targetParamCount, hostPackageName) + } + + val result = arrayOfNulls(targetParamCount) + for (i in 0 until targetParamCount) { + val value = if (i < mutable.size) mutable[i] else null + result[i] = coerceValueForType(value, targetTypes[i]) + } + clearExternalServiceFlag(result) + return result + } + + private fun trimToTargetCount(mutable: MutableList, targetCount: Int, hostPackageName: String?): Boolean { + while (mutable.size > targetCount) { + val diff = mutable.size - targetCount + val intentIdx = indexOfFirstIntent(mutable) + if (intentIdx < 0) return false + val flagsIdx = findFirstFlagsIndexAfter(mutable, intentIdx) + val userIdIdx = findLastIntegerIndex(mutable) + if (diff == 1 && flagsIdx > intentIdx && userIdIdx > flagsIdx) { + val remove = pickExtraSlotBetween(mutable, flagsIdx, userIdIdx, hostPackageName) + if (remove >= 0) mutable.removeAt(remove) else return false + } else { + return false + } + } + return mutable.size == targetCount + } + + private fun legacyTrimToTargetCount(mutable: MutableList, targetCount: Int, hostPackageName: String?) { + while (mutable.size > targetCount) { + var removeIndex = findInstanceNameIndexLegacy(mutable, hostPackageName) + if (removeIndex < 0) removeIndex = findNullableGapIndexLegacy(mutable) + if (removeIndex < 0) { + val flagsIndex = findFirstFlagsIndexWholeList(mutable) + removeIndex = if (flagsIndex >= 0 && flagsIndex + 1 < mutable.size) flagsIndex + 1 else mutable.size - 1 + } + mutable.removeAt(removeIndex) + } + } + + private fun indexOfFirstIntent(args: List): Int = args.indexOfFirst { it is Intent } + + private fun findFirstFlagsIndexAfter(args: List, intentIdx: Int): Int { + val start = if (intentIdx >= 0) intentIdx + 1 else 0 + for (i in start until args.size) { + if (args[i] is Int || args[i] is Long) return i + } + return -1 + } + + private fun findFirstFlagsIndexWholeList(args: List): Int { + for (i in args.indices) if (args[i] is Int || args[i] is Long) return i + return -1 + } + + private fun findLastIntegerIndex(args: List): Int { + for (i in args.indices.reversed()) if (args[i] is Int) return i + return -1 + } + + private fun pickExtraSlotBetween(args: List, flagsIdx: Int, userIdIdx: Int, hostPackageName: String?): Int { + for (i in flagsIdx + 1 until userIdIdx) { + val v = args[i] + if (v == null) return i + if (v is String && (hostPackageName == null || hostPackageName != v)) return i + } + for (i in flagsIdx + 1 until userIdIdx) if (args[i] is String) return i + return -1 + } + + private fun findInstanceNameIndexLegacy(args: List, hostPackageName: String?): Int { + val flagsIndex = findFirstFlagsIndexWholeList(args) + val userIdIndex = findLastIntegerIndex(args) + if (flagsIndex < 0 || userIdIndex <= flagsIndex) return -1 + for (i in flagsIndex + 1 until userIdIndex) { + val value = args[i] as? String ?: continue + if (hostPackageName != null && value == hostPackageName) continue + return i + } + return -1 + } + + private fun findNullableGapIndexLegacy(args: List): Int { + val flagsIndex = findFirstFlagsIndexWholeList(args) + val userIdIndex = findLastIntegerIndex(args) + if (flagsIndex < 0 || userIdIndex <= flagsIndex) return -1 + for (i in flagsIndex + 1 until userIdIndex) if (args[i] == null) return i + return -1 + } + + private fun coerceValueForType(value: Any?, targetType: Class<*>): Any? = when (targetType) { + java.lang.Long.TYPE, java.lang.Long::class.javaObjectType -> + (value as? Number)?.toLong() ?: 0L + Integer.TYPE, Integer::class.javaObjectType -> + (value as? Number)?.toInt() ?: 0 + java.lang.Boolean.TYPE, java.lang.Boolean::class.javaObjectType -> + value as? Boolean ?: false + else -> value + } + + private fun clearExternalServiceFlag(args: Array) { + var mask = 0x00800000L or 0x80000000L + try { + mask = mask or Context.BIND_EXTERNAL_SERVICE.toLong() + } catch (_: Throwable) { + } + for (i in args.indices) { + when (val v = args[i]) { + is Int -> { + val m = (mask and 0xFFFFFFFFL).toInt() + args[i] = v and m.inv() + return + } + is Long -> { + args[i] = v and mask.inv() + return + } + } + } + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/BinderHook.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/BinderHook.kt new file mode 100644 index 00000000..a7b82b1b --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/BinderHook.kt @@ -0,0 +1,46 @@ +package app.risuko.webview_upgrade.swap + +import android.os.IBinder + +internal abstract class BinderHook { + + private val lock = Any() + private var originalBinder: IBinder? = null + @Volatile + protected var proxyBinder: ProxyBinder? = null + private set + private var currentlyHooked = false + private var everHooked = false + + fun hook() = synchronized(lock) { + if (currentlyHooked) return + if (everHooked) { + onProxyBinderReplace(proxyBinder!!) + } else { + val original = onTargetBinderObtain() + val proxy = onProxyBinderCreate(original) + try { + onProxyBinderReplace(proxy) + } catch (t: Throwable) { + runCatching { onTargetBinderRestore(original) }.onFailure { t.addSuppressed(it) } + throw t + } + originalBinder = original + proxyBinder = proxy + everHooked = true + } + currentlyHooked = true + } + + fun restore(): Boolean = synchronized(lock) { + if (!currentlyHooked) return false + onTargetBinderRestore(originalBinder!!) + currentlyHooked = false + true + } + + protected abstract fun onTargetBinderObtain(): IBinder + protected abstract fun onProxyBinderCreate(binder: IBinder): ProxyBinder + protected abstract fun onTargetBinderRestore(binder: IBinder) + protected abstract fun onProxyBinderReplace(binder: ProxyBinder) +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/Framework.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/Framework.kt new file mode 100644 index 00000000..1aba70f0 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/Framework.kt @@ -0,0 +1,78 @@ +package app.risuko.webview_upgrade.swap + +import android.content.Context +import android.content.ContextWrapper +import android.os.IBinder +import android.os.IInterface +import android.util.Log +import app.risuko.webview_upgrade.LOG_TAG +import app.risuko.webview_upgrade.reflect.Reflect + + +internal object Framework { + + const val SERVICE_WEBVIEW_UPDATE = "webviewupdate" + const val SERVICE_PACKAGE = "package" + + private val serviceManager get() = Reflect.cls("android.os.ServiceManager") + private val webViewFactory get() = Reflect.cls("android.webkit.WebViewFactory") + private val activityThread get() = Reflect.cls("android.app.ActivityThread") + + fun getService(name: String): IBinder? = + Reflect.callStatic(serviceManager, "getService", name) as? IBinder + + @Suppress("UNCHECKED_CAST") + fun serviceCache(): MutableMap = + Reflect.getStaticField(serviceManager, "sCache") as MutableMap + + fun providerInstance(): Any? = Reflect.getStaticField(webViewFactory, "sProviderInstance") + + fun webViewUpdateAsInterface(binder: IBinder): IInterface = + Reflect.callStatic( + Reflect.cls("android.webkit.IWebViewUpdateService\$Stub"), + "asInterface", + binder, + ) as IInterface + + fun setResponsePackageInfo(response: Any, packageInfo: Any?) = + Reflect.setField(response, "packageInfo", packageInfo) + + fun packageManagerAsInterface(binder: IBinder): IInterface = + Reflect.callStatic( + Reflect.cls("android.content.pm.IPackageManager\$Stub"), + "asInterface", + binder, + ) as IInterface + + fun currentActivityThread(): Any? = + Reflect.callStatic(activityThread, "currentActivityThread") + + fun setActivityThreadPackageManager(iface: IInterface?) { + Reflect.setStaticField(activityThread, "sPackageManager", iface) + } + + fun invalidateLoadedApkCache(packageName: String) { + try { + val at = currentActivityThread() ?: return + for (field in arrayOf("mPackages", "mResourcePackages")) { + try { + val map = Reflect.getField(at, field) + if (map is MutableMap<*, *>) map.remove(packageName) + } catch (_: Throwable) { + } + } + } catch (t: Throwable) { + Log.w(LOG_TAG, "invalidateLoadedApkCache failed: ${t.message}") + } + } + + /** Clear ContextImpl's cached PackageManager so later queries hit the proxy */ + fun flushContextImplPackageManager(context: Context) { + var base: Context = context.applicationContext ?: return + while (base is ContextWrapper) base = base.baseContext + try { + Reflect.setField(base, "mPackageManager", null) + } catch (_: Throwable) { + } + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/PackageManagerHook.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/PackageManagerHook.kt new file mode 100644 index 00000000..22dc0211 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/PackageManagerHook.kt @@ -0,0 +1,211 @@ +package app.risuko.webview_upgrade.swap + +import android.content.ComponentName +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.IInterface +import android.os.Process +import android.util.Log +import app.risuko.webview_upgrade.LOG_TAG +import app.risuko.webview_upgrade.reflect.Reflect +import java.io.File + +internal class PackageManagerHook( + private val context: Context, + private val webViewPackageName: String, + private val apkPath: String, + private val libsPath: String, +) : BinderHook() { + + private lateinit var binderCache: MutableMap + private var cachedWebViewAppInfo: ApplicationInfo? = null + + private val interceptors: Map = mapOf( + "getPackageInfo" to Reflect.Interceptor { args, original -> + val pkg = args.getOrNull(0) as? String ?: return@Interceptor original() + if (pkg != webViewPackageName) return@Interceptor original() + getWebViewPackageInfo(intFlag(args)) + ?: throw RuntimeException("apkPath is not valid $apkPath") + }, + "getApplicationInfo" to Reflect.Interceptor { args, original -> + val pkg = args.getOrNull(0) as? String ?: return@Interceptor original() + if (pkg != webViewPackageName) return@Interceptor original() + Framework.invalidateLoadedApkCache(webViewPackageName) + getWebViewApplicationInfo(intFlag(args)) + ?: throw RuntimeException("apkPath is not valid $apkPath") + }, + "getComponentEnabledSetting" to Reflect.Interceptor { args, original -> + val cn = args.getOrNull(0) as? ComponentName ?: return@Interceptor original() + if (cn.packageName == webViewPackageName) { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } else { + original() + } + }, + "getServiceInfo" to Reflect.Interceptor { args, original -> + val cn = args.getOrNull(0) as? ComponentName ?: return@Interceptor original() + handleGetServiceInfo(cn) ?: original() + }, + "getInstallerPackageName" to Reflect.Interceptor { args, original -> + val pkg = args.getOrNull(0) as? String + if (pkg == webViewPackageName) "com.android.vending" else original() + }, + "asBinder" to Reflect.Interceptor { _, original -> + proxyBinder ?: original() + }, + ) + + private fun intFlag(args: Array): Int = (args.getOrNull(1) as? Number)?.toInt() ?: 0 + + private fun getWebViewApplicationInfo(flags: Int): ApplicationInfo? = + getWebViewPackageInfo(flags)?.applicationInfo + + private fun getWebViewPackageInfo(flags: Int): PackageInfo? { + var f = flags + var pi = context.packageManager.getPackageArchiveInfo(apkPath, f) + if (pi == null) { + @Suppress("DEPRECATION") + f = f and PackageManager.GET_SIGNATURES.inv() + pi = context.packageManager.getPackageArchiveInfo(apkPath, f) + } + if (pi == null) return null + fillPackageInfo(pi) + return pi + } + + private fun fillPackageInfo(packageInfo: PackageInfo) { + val is64Bit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) Process.is64Bit() else false + val preferredAbis = + (if (is64Bit) Build.SUPPORTED_64_BIT_ABIS else Build.SUPPORTED_32_BIT_ABIS).clone() + + val libsDir = File(libsPath) + if (!libsDir.exists()) throw RuntimeException("libsDir not exist $libsPath") + if (libsDir.list() == null) throw RuntimeException("abi dir not exist in $libsPath") + + val resolved = resolveNativeLibraryDir(libsDir, is64Bit) + ?: throw NullPointerException( + "unable to find supported abis ${preferredAbis.contentToString()} in dir $libsPath" + ) + val (cpuAbi, nativeLibraryDir) = resolved + + val ai = packageInfo.applicationInfo ?: return + runCatching { Reflect.setField(ai, "primaryCpuAbi", cpuAbi) } + runCatching { Reflect.setField(ai, "nativeLibraryRootDir", libsPath) } + ai.nativeLibraryDir = nativeLibraryDir + ai.sourceDir = apkPath + ai.publicSourceDir = apkPath + ai.flags = ai.flags or ApplicationInfo.FLAG_INSTALLED or ApplicationInfo.FLAG_HAS_CODE + cachedWebViewAppInfo = ai + } + + private fun handleGetServiceInfo(component: ComponentName): ServiceInfo? { + val className = component.className ?: return null + if (component.packageName != webViewPackageName || + !className.contains("SandboxedProcessService") + ) { + return null + } + return ServiceInfo().apply { + name = className + packageName = component.packageName + exported = true + enabled = true + applicationInfo = cachedWebViewAppInfo ?: getWebViewApplicationInfo(0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + flags = flags or ServiceInfo.FLAG_ISOLATED_PROCESS or ServiceInfo.FLAG_EXTERNAL_SERVICE + } + } + } + + private fun resolveNativeLibraryDir(libsDir: File, is64Bit: Boolean): Pair? { + val preferredAbis = + (if (is64Bit) Build.SUPPORTED_64_BIT_ABIS else Build.SUPPORTED_32_BIT_ABIS).clone() + val sortedAbis = preferredAbis.clone().also { it.sort() } + val list = libsDir.list() ?: return null + + // .so directly in the root + if (list.any { it.endsWith(".so") }) { + val cpuAbi = preferredAbis.firstOrNull() ?: return null + Log.w(LOG_TAG, "native .so in lib root, cpuAbi=$cpuAbi") + return cpuAbi to libsDir.absolutePath + } + // canonical ABI subdir present as-is + for (name in list) { + if (sortedAbis.binarySearchExists(name)) { + val d = File(libsDir, name) + if (d.isDirectory) return name to d.absolutePath + } + } + // canonical match, then short dir name (arm/arm64) + for (canonical in preferredAbis) { + val d = File(libsDir, canonical) + if (d.isDirectory) return canonical to d.absolutePath + shortDirNameFor(canonical)?.let { short -> + val d2 = File(libsDir, short) + if (d2.isDirectory) return canonical to d2.absolutePath + } + } + // any subdir that contains a .so + for (name in list) { + val sub = File(libsDir, name) + if (!sub.isDirectory) continue + val subList = sub.list() ?: continue + if (subList.any { it.endsWith(".so") }) { + val cpuAbi = canonicalAbiFromDiskName(name, preferredAbis) + ?: name.takeIf { sortedAbis.binarySearchExists(it) } + ?: preferredAbis.firstOrNull() ?: name + return cpuAbi to sub.absolutePath + } + } + return null + } + + private fun Array.binarySearchExists(name: String): Boolean = + java.util.Arrays.binarySearch(this, name) >= 0 + + private fun shortDirNameFor(canonical: String): String? = when (canonical) { + "arm64-v8a" -> "arm64" + "armeabi-v7a", "armeabi" -> "arm" + else -> null + } + + private fun canonicalAbiFromDiskName(diskName: String, preferredAbis: Array): String? { + if (diskName == "arm64") { + preferredAbis.firstOrNull { it == "arm64-v8a" }?.let { return it } + preferredAbis.firstOrNull { it.startsWith("arm64") }?.let { return it } + } + if (diskName == "arm") { + preferredAbis.firstOrNull { it == "armeabi-v7a" || it == "armeabi" }?.let { return it } + } + return null + } + + override fun onTargetBinderObtain(): IBinder = + Framework.getService(Framework.SERVICE_PACKAGE) + ?: throw IllegalStateException("package service unavailable") + + override fun onProxyBinderCreate(binder: IBinder): ProxyBinder { + val realInterface: IInterface = Framework.packageManagerAsInterface(binder) + val proxyInterface = Reflect.newInterfaceProxy(realInterface, interceptors) as IInterface + binderCache = Framework.serviceCache() + return ProxyBinder(realInterface.asBinder(), proxyInterface) + } + + override fun onTargetBinderRestore(binder: IBinder) { + val realInterface = binder.queryLocalInterface(binder.interfaceDescriptor ?: "") + binderCache[Framework.SERVICE_PACKAGE] = binder + Framework.setActivityThreadPackageManager(realInterface) + Framework.flushContextImplPackageManager(context) + } + + override fun onProxyBinderReplace(binder: ProxyBinder) { + binderCache[Framework.SERVICE_PACKAGE] = binder + Framework.setActivityThreadPackageManager(binder.currentProxyInterface) + Framework.flushContextImplPackageManager(context) + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/ProxyBinder.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/ProxyBinder.kt new file mode 100644 index 00000000..cdf6e04f --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/ProxyBinder.kt @@ -0,0 +1,37 @@ +package app.risuko.webview_upgrade.swap + +import android.os.IBinder +import android.os.IInterface +import android.os.Parcel +import java.io.FileDescriptor + +internal class ProxyBinder( + private val remote: IBinder, + initialProxyInterface: IInterface, +) : IBinder { + + @Volatile + private var proxyInterface: IInterface = initialProxyInterface + + val currentProxyInterface: IInterface get() = proxyInterface + + fun setProxyInterface(iface: IInterface) { + proxyInterface = iface + } + + override fun queryLocalInterface(descriptor: String): IInterface = proxyInterface + + override fun getInterfaceDescriptor(): String? = remote.interfaceDescriptor + override fun pingBinder(): Boolean = remote.pingBinder() + override fun isBinderAlive(): Boolean = remote.isBinderAlive + override fun dump(fd: FileDescriptor, args: Array?) = remote.dump(fd, args) + override fun dumpAsync(fd: FileDescriptor, args: Array?) = remote.dumpAsync(fd, args) + override fun transact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean = + remote.transact(code, data, reply, flags) + + override fun linkToDeath(recipient: IBinder.DeathRecipient, flags: Int) = + remote.linkToDeath(recipient, flags) + + override fun unlinkToDeath(recipient: IBinder.DeathRecipient, flags: Int): Boolean = + remote.unlinkToDeath(recipient, flags) +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/WebViewSwap.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/WebViewSwap.kt new file mode 100644 index 00000000..bfc8543d --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/WebViewSwap.kt @@ -0,0 +1,110 @@ +package app.risuko.webview_upgrade.swap + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.Looper +import android.util.Log +import android.webkit.RenderProcessGoneDetail +import android.webkit.WebView +import android.webkit.WebViewClient +import app.risuko.webview_upgrade.LOG_TAG +import java.io.File + +internal object WebViewSwap { + + /** The PackageInfo of the kernel we swapped to; read by [ActivityManagerHook] */ + @Volatile + var replaceWebViewPackageInfo: PackageInfo? = null + private set + + private var packageManagerHook: PackageManagerHook? = null + private var activityManagerHook: ActivityManagerHook? = null + + class PreconditionException(message: String) : Exception(message) + + /** + * @return the PackageInfo now backing WebView (its `versionName` is the new + * kernel version), or null if read-back failed. + * @throws PreconditionException if called too late / off the main thread. + */ + @Synchronized + fun swapToInstalledPackage(context: Context, packageName: String): PackageInfo? { + checkPreconditions() + + val packageInfo = context.packageManager.getPackageInfo( + packageName, + PackageManager.GET_SHARED_LIBRARY_FILES or PackageManager.GET_META_DATA, + ) + val appInfo = packageInfo.applicationInfo + ?: throw PreconditionException("no applicationInfo for $packageName") + val apkPath: String? = appInfo.sourceDir + val libsPath: String? = deriveNativeLibsRoot(appInfo) + + if (packageManagerHook == null && apkPath != null && libsPath != null) { + packageManagerHook = PackageManagerHook(context, packageName, apkPath, libsPath) + } + val updateServiceHook = WebViewUpdateServiceHook(context, packageName) + if (activityManagerHook == null) { + activityManagerHook = ActivityManagerHook(context, packageName, apkPath, libsPath) + } + + var success = false + try { + packageManagerHook?.hook() + updateServiceHook.hook() + activityManagerHook?.hook() + + lockProviderBinding(context) + replaceWebViewPackageInfo = currentWebViewPackage() + success = true + Log.i(LOG_TAG, "swap succeeded -> $packageName (${replaceWebViewPackageInfo?.versionName})") + return replaceWebViewPackageInfo + } finally { + runCatching { updateServiceHook.restore() } + if (!success) { + runCatching { packageManagerHook?.restore() } + packageManagerHook = null + runCatching { activityManagerHook?.restore() } + activityManagerHook = null + } + } + } + + private fun checkPreconditions() { + if (Looper.myLooper() != Looper.getMainLooper()) { + throw PreconditionException("swap must run on the main thread") + } + if (Framework.providerInstance() != null) { + throw PreconditionException("WebView provider already bound; swap is too late") + } + } + + private fun lockProviderBinding(context: Context) { + val webView = WebView(context) + webView.webViewClient = object : WebViewClient() { + override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { + Log.w(LOG_TAG, "throwaway WebView render process gone, didCrash=${detail.didCrash()}") + return true + } + } + } + + private fun currentWebViewPackage(): PackageInfo? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + runCatching { WebView.getCurrentWebViewPackage() }.getOrNull() + } else { + null + } + + private fun deriveNativeLibsRoot(appInfo: android.content.pm.ApplicationInfo): String? { + runCatching { + val f = android.content.pm.ApplicationInfo::class.java.getDeclaredField("nativeLibraryRootDir") + f.isAccessible = true + (f.get(appInfo) as? String)?.takeIf { it.isNotEmpty() } + }.getOrNull()?.let { return it } + return appInfo.nativeLibraryDir?.takeIf { it.isNotEmpty() } + ?.let { File(it).parentFile?.absolutePath } + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/WebViewUpdateServiceHook.kt b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/WebViewUpdateServiceHook.kt new file mode 100644 index 00000000..f0977060 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/java/app/risuko/webview_upgrade/swap/WebViewUpdateServiceHook.kt @@ -0,0 +1,79 @@ +package app.risuko.webview_upgrade.swap + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.IBinder +import android.os.IInterface +import android.os.Parcel +import android.os.Parcelable +import app.risuko.webview_upgrade.reflect.Reflect + +internal class WebViewUpdateServiceHook( + private val context: Context, + private val webViewPackageName: String, +) : BinderHook() { + + private lateinit var binderCache: MutableMap + + private val interceptors: Map = mapOf( + "waitForAndGetProvider" to Reflect.Interceptor { _, original -> + rewriteProviderResponse(original()) + }, + "asBinder" to Reflect.Interceptor { _, original -> + proxyBinder ?: original() + }, + "isMultiProcessEnabled" to Reflect.Interceptor { _, _ -> true }, + ) + + private fun rewriteProviderResponse(original: Any?): Any? { + val response = original ?: return null + val packageInfo = try { + context.packageManager.getPackageInfo( + webViewPackageName, + PackageManager.GET_SHARED_LIBRARY_FILES + or PackageManager.GET_SIGNATURES + or PackageManager.GET_META_DATA, + ) + } catch (e: PackageManager.NameNotFoundException) { + throw RuntimeException(e) + } + Framework.setResponsePackageInfo(response, packageInfo) + + val parcel = Parcel.obtain() + return try { + parcel.writeParcelable(response as Parcelable, 0) + parcel.setDataPosition(parcel.dataSize() - 4) + parcel.writeInt(0) + parcel.setDataPosition(0) + val loader = response.javaClass.classLoader + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + parcel.readParcelable(loader, response.javaClass) + } else { + @Suppress("DEPRECATION") + parcel.readParcelable(loader) + } + } finally { + parcel.recycle() + } + } + + override fun onTargetBinderObtain(): IBinder = + Framework.getService(Framework.SERVICE_WEBVIEW_UPDATE) + ?: throw IllegalStateException("webviewupdate service unavailable") + + override fun onProxyBinderCreate(binder: IBinder): ProxyBinder { + val realInterface: IInterface = Framework.webViewUpdateAsInterface(binder) + val proxyInterface = Reflect.newInterfaceProxy(realInterface, interceptors) as IInterface + binderCache = Framework.serviceCache() + return ProxyBinder(realInterface.asBinder(), proxyInterface) + } + + override fun onTargetBinderRestore(binder: IBinder) { + binderCache[Framework.SERVICE_WEBVIEW_UPDATE] = binder + } + + override fun onProxyBinderReplace(binder: ProxyBinder) { + binderCache[Framework.SERVICE_WEBVIEW_UPDATE] = binder + } +} diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/res/values-ja/strings.xml b/src-tauri/risuko-webview-upgrade/android/src/main/res/values-ja/strings.xml new file mode 100644 index 00000000..0902aba6 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/res/values-ja/strings.xml @@ -0,0 +1,6 @@ + + + お使いの端末の Android System WebView が古いため、このアプリが正しく表示されない場合があります。Android System WebView を更新またはインストールしてください。 + 更新 + 後で + diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/res/values-zh-rCN/strings.xml b/src-tauri/risuko-webview-upgrade/android/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 00000000..2f2637e2 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,6 @@ + + + 您设备的 Android System WebView 版本过旧,应用可能无法正常显示。请更新或安装 Android System WebView。 + 更新 + 暂不 + diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/res/values-zh-rTW/strings.xml b/src-tauri/risuko-webview-upgrade/android/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 00000000..b7bb83e3 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,6 @@ + + + 您裝置的 Android System WebView 版本過舊,應用程式可能無法正常顯示。請更新或安裝 Android System WebView。 + 更新 + 暫不 + diff --git a/src-tauri/risuko-webview-upgrade/android/src/main/res/values/strings.xml b/src-tauri/risuko-webview-upgrade/android/src/main/res/values/strings.xml new file mode 100644 index 00000000..5d8a1950 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/android/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Your device\'s Android System WebView is outdated, so this app may not display correctly. Please update or install Android System WebView. + Update + Not now + diff --git a/src-tauri/risuko-webview-upgrade/build.rs b/src-tauri/risuko-webview-upgrade/build.rs new file mode 100644 index 00000000..83a5cfb2 --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/build.rs @@ -0,0 +1,8 @@ +// This plugin exposes no commands +const COMMANDS: &[&str] = &[]; + +fn main() { + tauri_plugin::Builder::new(COMMANDS) + .android_path("android") + .build(); +} diff --git a/src-tauri/risuko-webview-upgrade/src/lib.rs b/src-tauri/risuko-webview-upgrade/src/lib.rs new file mode 100644 index 00000000..27107b9f --- /dev/null +++ b/src-tauri/risuko-webview-upgrade/src/lib.rs @@ -0,0 +1,12 @@ +//! Android WebView kernel-upgrade plugin. + +use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, +}; + +const PLUGIN_NAME: &str = "webview-upgrade"; + +pub fn init() -> TauriPlugin { + Builder::new(PLUGIN_NAME).build() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 880b446b..2d7083aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,7 +45,7 @@ fn with_desktop_plugins(builder: tauri::Builder) -> tauri: #[cfg(target_os = "android")] fn with_desktop_plugins(builder: tauri::Builder) -> tauri::Builder { - builder + builder.plugin(risuko_webview_upgrade::init()) } /// Resolve which directory the log appender should write to diff --git a/src-tauri/src/managers/flyout.rs b/src-tauri/src/managers/flyout.rs index 84a52486..d4e0555b 100644 --- a/src-tauri/src/managers/flyout.rs +++ b/src-tauri/src/managers/flyout.rs @@ -5,9 +5,6 @@ use tauri::{ AppHandle, Emitter, Manager, PhysicalPosition, WebviewUrl, WebviewWindow, WebviewWindowBuilder, }; -#[cfg(target_os = "android")] -use tauri::AppHandle; - #[cfg(not(target_os = "android"))] pub const FLYOUT_LABEL: &str = "tray-panel"; @@ -98,9 +95,6 @@ pub fn toggle_flyout(app: &AppHandle) { let _ = window.emit("flyout:show", ()); } -#[cfg(target_os = "android")] -pub fn toggle_flyout(_app: &AppHandle) {} - /// Position the flyout near the cached tray anchor #[cfg(not(target_os = "android"))] fn position_flyout(app: &AppHandle, window: &WebviewWindow) { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7121146c..c2b23a22 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -16,6 +16,7 @@ pub struct AppState { pub upload_sinks: Mutex>>, pub vault: Arc, pub log_dir: PathBuf, + #[cfg(not(target_os = "android"))] pub tray_anchor: Mutex>, /// Keeps the file logger alive; dropped when AppState is dropped. pub _log_guard: Option, @@ -44,6 +45,7 @@ impl AppState { upload_sinks: Mutex::new(Some(Arc::new(upload_manager))), vault: Arc::new(VaultManager::new()), log_dir, + #[cfg(not(target_os = "android"))] tray_anchor: Mutex::new(None), _log_guard: Some(log_guard), }) diff --git a/src-tauri/src/utils/run_mode.rs b/src-tauri/src/utils/run_mode.rs index 0cf3b501..6fa643af 100644 --- a/src-tauri/src/utils/run_mode.rs +++ b/src-tauri/src/utils/run_mode.rs @@ -1,7 +1,9 @@ use tauri::{AppHandle, Manager}; pub const RUN_MODE_STANDARD: i64 = 1; +#[cfg(not(target_os = "android"))] pub const RUN_MODE_TRAY: i64 = 2; +#[cfg(not(target_os = "android"))] pub const RUN_MODE_HIDE_TRAY_LEGACY: i64 = 3; /// Get the current run mode from app config (1 = standard, 2 = tray, 3 = hide tray legacy) @@ -20,6 +22,7 @@ pub fn current_run_mode(handle: &AppHandle) -> i64 { } /// Check if the current run mode is a tray mode (2 or 3) +#[cfg(not(target_os = "android"))] pub fn is_tray_mode(run_mode: i64) -> bool { matches!(run_mode, RUN_MODE_TRAY | RUN_MODE_HIDE_TRAY_LEGACY) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 87159da1..59cb689e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -75,6 +75,10 @@ "https://risuko.vercel.app/api/update/{{target}}/{{arch}}/{{current_version}}" ], "pubkey": "" + }, + "webview-upgrade": { + "minUpgradeMajor": 106, + "minSupportedMajor": 75 } } } diff --git a/src/renderer/components/DragSelect/Index.vue b/src/renderer/components/DragSelect/Index.vue index 1e237a59..36c0b5f0 100644 --- a/src/renderer/components/DragSelect/Index.vue +++ b/src/renderer/components/DragSelect/Index.vue @@ -50,12 +50,9 @@ export default { }, computed: { containerStyle() { - // On Android, allow normal touch scrolling and synthetic click - // generation. The drag-rectangle gesture is a desktop-only - // pattern; preventing default on touchstart there blocks both - // page scroll and every per-card button click. const touchAction = is.android() ? "auto" : "none"; - return `position: relative; user-select: none; overflow-x: hidden; touch-action: ${touchAction};`; + const overflowX = is.android() ? "visible" : "hidden"; + return `position: relative; user-select: none; overflow-x: ${overflowX}; touch-action: ${touchAction};`; }, }, watch: { diff --git a/src/renderer/styles/android.css b/src/renderer/styles/android.css index a7df4570..5f795010 100644 --- a/src/renderer/styles/android.css +++ b/src/renderer/styles/android.css @@ -399,6 +399,14 @@ overscroll-behavior: contain; } + html.platform-android .task-list, + html.mobile-phone .task-list { + flex: none; + overflow-y: visible; + height: auto; + padding: 8px 14px 18px; + } + html.platform-android .subnav, html.mobile-phone .subnav { width: 100% !important; @@ -529,18 +537,6 @@ border-radius: calc(var(--android-radius-md) - 4px) !important; } - html.platform-android .task-list, - html.mobile-phone .task-list { - height: auto; - padding: 8px 14px 18px; - overflow: visible; - } - - /* The card uses a single rounded outer shape with overflow:hidden so - the colored status indicator (first flex child) and the content - area (second flex child) are clipped to that one shape. The user - then sees a unified rounded rectangle whose left slice is the - colored status bar — the bar is "connected" to the card. */ html.platform-android .task-item, html.mobile-phone .task-item { min-height: 116px; @@ -820,6 +816,83 @@ background: var(--android-surface-container); } + html.platform-android .add-task-dialog [data-slot="input"], + html.mobile-phone .add-task-dialog [data-slot="input"] { + height: 48px; + min-height: 48px; + padding-top: 0; + padding-bottom: 0; + line-height: 48px; + display: flex; + align-items: center; + } + html.platform-android .add-task-dialog [data-slot="textarea"], + html.mobile-phone .add-task-dialog [data-slot="textarea"] { + padding-top: 12px; + padding-bottom: 12px; + } + html.platform-android .add-task-dialog .mo-input-prepend, + html.platform-android .add-task-dialog .mo-input-append, + html.mobile-phone .add-task-dialog .mo-input-prepend, + html.mobile-phone .add-task-dialog .mo-input-append { + height: 48px; + min-height: 48px; + } + + /* On Android, flatten the path selector container border but + keep the history and folder buttons visible. Give the folder + button a distinct background so it doesn't blend in. */ + html.platform-android .add-task-dialog .mo-input-group--bordered, + html.mobile-phone .add-task-dialog .mo-input-group--bordered { + border: 1px solid var(--android-outline-variant) !important; + background: var(--android-surface-container) !important; + border-radius: var(--android-radius-md) !important; + overflow: hidden; + } + html.platform-android + .add-task-dialog + .mo-input-group--bordered + > .mo-input-prepend, + html.mobile-phone + .add-task-dialog + .mo-input-group--bordered + > .mo-input-prepend { + border: none !important; + background: var(--android-surface-container-high) !important; + } + html.platform-android + .add-task-dialog + .mo-input-group--bordered + > .mo-input-append, + html.mobile-phone + .add-task-dialog + .mo-input-group--bordered + > .mo-input-append { + border: none !important; + border-left: 1px solid var(--android-outline-variant) !important; + background: var(--android-surface-container-high) !important; + } + html.platform-android .add-task-dialog .path-indicator-field, + html.mobile-phone .add-task-dialog .path-indicator-field { + border: none !important; + border-radius: 0 !important; + background: transparent !important; + } + + /* Match NumberInput height to the path selector for consistency */ + html.platform-android .add-task-dialog .mo-number-input-wrap, + html.mobile-phone .add-task-dialog .mo-number-input-wrap { + height: 48px; + min-height: 48px; + border-radius: var(--android-radius-md); + background: var(--android-surface-container); + border-color: var(--android-outline-variant); + } + html.platform-android .add-task-dialog .mo-number-input-wrap .mo-input-number, + html.mobile-phone .add-task-dialog .mo-number-input-wrap .mo-input-number { + height: 100%; + } + html.platform-android .select-directory, html.mobile-phone .select-directory { min-width: 48px;