Browse Source

添加浏览器模块

cc12458 1 month ago
parent
commit
1f63077f55
38 changed files with 1347 additions and 1 deletions
  1. 2 0
      .idea/gradle.xml
  2. 1 0
      .idea/inspectionProfiles/Project_Default.xml
  3. 0 1
      .idea/misc.xml
  4. 1 0
      app/build.gradle.kts
  5. 4 0
      app/src/main/AndroidManifest.xml
  6. 3 0
      app/src/main/java/com/hzliuzhi/applet/container/navigation/Host.kt
  7. 78 0
      core/src/main/assets/browser/bridge.js
  8. 17 0
      core/src/main/java/com/hzliuzhi/applet/core/SharedFlowHub.kt
  9. 98 0
      core/src/main/java/com/hzliuzhi/applet/core/util/SystemPropertiesProxy.java
  10. 9 0
      gradle/libs.versions.toml
  11. 1 0
      library/browser/.gitignore
  12. 60 0
      library/browser/build.gradle.kts
  13. 0 0
      library/browser/consumer-rules.pro
  14. 21 0
      library/browser/proguard-rules.pro
  15. 24 0
      library/browser/src/androidTest/java/com/hzliuzhi/applet/browser/ExampleInstrumentedTest.kt
  16. 16 0
      library/browser/src/main/AndroidManifest.xml
  17. 30 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/navigation/Route.kt
  18. 30 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/proxy/Config.kt
  19. 60 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/proxy/Server.kt
  20. 108 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/request/Client.kt
  21. 44 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/KioskScreen.kt
  22. 89 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/WebScreen.kt
  23. 50 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/components/ProgressBar.kt
  24. 8 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebContent.kt
  25. 69 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebProxy.kt
  26. 44 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebSettings.kt
  27. 17 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebState.kt
  28. 53 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebView.kt
  29. 74 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewBridge.kt
  30. 56 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewController.kt
  31. 72 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewNavigator.kt
  32. 72 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewPermission.kt
  33. 39 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WrapperWebChromeClient.kt
  34. 61 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WrapperWebViewClient.kt
  35. 5 0
      library/browser/src/main/res/values/proxy.xml
  36. 13 0
      library/browser/src/main/res/xml/network_security_config.xml
  37. 17 0
      library/browser/src/test/java/com/hzliuzhi/applet/browser/ExampleUnitTest.kt
  38. 1 0
      settings.gradle.kts

+ 2 - 0
.idea/gradle.xml

@@ -12,6 +12,8 @@
             <option value="$PROJECT_DIR$" />
             <option value="$PROJECT_DIR$/app" />
             <option value="$PROJECT_DIR$/core" />
+            <option value="$PROJECT_DIR$/library" />
+            <option value="$PROJECT_DIR$/library/browser" />
           </set>
         </option>
       </GradleProjectSettings>

+ 1 - 0
.idea/inspectionProfiles/Project_Default.xml

@@ -57,5 +57,6 @@
       <option name="composableFile" value="true" />
       <option name="previewFile" value="true" />
     </inspection_tool>
+    <inspection_tool class="UsePropertyAccessSyntax" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
   </profile>
 </component>

+ 0 - 1
.idea/misc.xml

@@ -1,4 +1,3 @@
-<?xml version="1.0" encoding="UTF-8"?>
 <project version="4">
   <component name="ExternalStorageConfigurationManager" enabled="true" />
   <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

+ 1 - 0
app/build.gradle.kts

@@ -58,4 +58,5 @@ dependencies {
   debugImplementation(libs.androidx.ui.test.manifest)
 
   implementation(project(":core"))
+  implementation(project(":library:browser"))
 }

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

@@ -22,6 +22,10 @@
         <category android:name="android.intent.category.LAUNCHER" />
       </intent-filter>
     </activity>
+
+    <meta-data
+        android:name="build_type_tag"
+        android:value="@string/app_id" />
   </application>
 
 </manifest>

+ 3 - 0
app/src/main/java/com/hzliuzhi/applet/container/navigation/Host.kt

@@ -8,6 +8,8 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.navigation.NavHostController
 import androidx.navigation.compose.NavHost
 import androidx.navigation.compose.rememberNavController
+
+import com.hzliuzhi.applet.browser.navigation.browser
 import com.hzliuzhi.applet.container.R
 
 @Composable
@@ -21,6 +23,7 @@ fun Host(
     modifier = modifier.fillMaxSize(),
   ) {
     app(navController = navController)
+    browser(navController = navController)
   }
 }
 

+ 78 - 0
core/src/main/assets/browser/bridge.js

@@ -0,0 +1,78 @@
+class Bridge extends EventTarget {
+  #analysis(string) {
+    let data = { type: '', payload: null, callbackId: '' };
+    try {
+      data = JSON.parse(string);
+    } catch (e) {
+      console.log('log:bridge:js', `[analysis] 解析消息错误: ${e.message}`);
+    }
+    if (
+      data.payload
+      && typeof data.payload === 'object'
+      && data.payload['__code__'] != null
+      && data.payload['__message__'] != null
+    ) {
+      data.error = { code: data.payload['__code__'], message: data.payload['__message__'] };
+    }
+    return data;
+  }
+
+  #pool = new Map();
+
+  static getInstance() {
+    return Bridge._instance ?? (Bridge._instance = new Bridge());
+  }
+
+  static get Platform() {
+    return window['AndroidBridge'];
+  }
+
+  static get UUID() {
+    return crypto.randomUUID();
+  }
+
+  static pulse(userId) {
+    const { promise, ...resolvers } = Promise.withResolvers();
+    this.getInstance().#postMessage('pulse', { userId }, resolvers);
+    return promise;
+  }
+
+  static print(payload) {
+    const { promise, ...resolvers } = Promise.withResolvers();
+    this.getInstance().#postMessage('print', payload, resolvers);
+    return promise;
+  }
+
+  dispatch(message) {
+    console.log('log:bridge:js', `[dispatch] 接收到消息: ${message}`);
+    const { type, callbackId, payload, error } = this.#analysis(message);
+    if (callbackId) {
+      const { resolve, reject } = this.#pool.get(callbackId) ?? {};
+      if (error) { reject?.(error); } else { resolve?.(payload); }
+      this.#pool.delete(callbackId);
+    } else {
+      let event;
+      if (error) event = new ErrorEvent(type, { error, message: error.message });
+      else event = new CustomEvent(type, { detail: payload });
+      super.dispatchEvent(event);
+    }
+  }
+
+  addEventListener(type, callback, options) {
+    super.addEventListener(type, callback, options);
+    return () => super.removeEventListener(type, callback);
+  }
+
+  #postMessage(type, payload, resolvers) {
+    const callbackId = `${type}:${Bridge.UUID}`;
+    this.#pool.set(callbackId, resolvers);
+    const message = JSON.stringify({ type, payload, callbackId })
+    Bridge.Platform.postMessage(message);
+    console.log('log:bridge:js', `[post] 发送的消息: ${message}`);
+  }
+}
+
+window['Bridge'] = Bridge;
+window['bridge'] = Bridge.getInstance();
+
+window.print = Bridge.print.bind(Bridge);

+ 17 - 0
core/src/main/java/com/hzliuzhi/applet/core/SharedFlowHub.kt

@@ -0,0 +1,17 @@
+package com.hzliuzhi.applet.core
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+
+object SharedFlowHub {
+  data class Event<T>(
+    val type: String,
+    val payload: T?,
+    val callback: (() -> Unit)? = null,
+  )
+
+  private val _events = MutableSharedFlow<Event<Any>>(extraBufferCapacity = 16)
+  val events = _events.asSharedFlow()
+
+  fun emit(event: Event<Any>) = _events.tryEmit(event)
+}

+ 98 - 0
core/src/main/java/com/hzliuzhi/applet/core/util/SystemPropertiesProxy.java

@@ -0,0 +1,98 @@
+package com.hzliuzhi.applet.core.util;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * Author elc_gulukai
+ * Time 2018/1/24
+ */
+public class SystemPropertiesProxy {
+
+
+  public static String getDeviceSN() {
+    return getProxy("ro.serialno", String.class);
+  }
+
+  public static String getString(String key) {
+    return getProxy(key, String.class);
+  }
+
+  public static String getString(String key, String def) {
+    return getDefaultProxy(key, "get", String.class, def);
+  }
+
+  public static int getInt(String key, int def) {
+    return getDefaultProxy(key, "getInt", int.class, def);
+  }
+
+  public static long getLong(String key, long def) {
+    return getDefaultProxy(key, "getLong", long.class, def);
+  }
+
+  public static boolean getBoolean(String key, boolean def) {
+    return getDefaultProxy(key, "getBoolean", boolean.class, def);
+  }
+
+  public static void set(String key, String val) {
+    setProxy(key, val);
+  }
+
+  /**
+   * Set the value for the given key.
+   *
+   * @throws IllegalArgumentException if the key exceeds 32 characters
+   * @throws IllegalArgumentException if the value exceeds 92 characters
+   */
+  private static void setProxy(String key, String val) {
+    try {
+      Class<?> c = Class.forName("android.os.SystemProperties");
+      Method set = c.getMethod("set", String.class, String.class);
+      set.invoke(c, key, val);
+    } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException |
+             IllegalArgumentException | InvocationTargetException e) {
+      e.printStackTrace();
+    }
+
+  }
+
+
+  /**
+   * Get the value for the given key.
+   *
+   * @return an empty string if the key isn't found
+   * @throws IllegalArgumentException if the key exceeds 32 characters
+   */
+  private static <T> T getProxy(String key, Class<T> paramsClass) {
+    T result = null;
+    try {
+      Class<?> c = Class.forName("android.os.SystemProperties");
+      Method get = c.getMethod("get", paramsClass);
+      result = (T) get.invoke(c, key);
+    } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException |
+             IllegalArgumentException | InvocationTargetException e) {
+      e.printStackTrace();
+    }
+    return result;
+  }
+
+  /**
+   * Get the value for the given key.
+   *
+   * @return an empty string if the key isn't found
+   * @throws IllegalArgumentException if the key exceeds 32 characters
+   */
+  private static <T> T getDefaultProxy(String key, String methodName, Class<T> paramsClass, T defaultValue) {
+    T result = defaultValue;
+    try {
+      Class<?> c = Class.forName("android.os.SystemProperties");
+      Method get = c.getMethod(methodName, String.class, paramsClass);
+      result = (T) get.invoke(c, key, defaultValue);
+    } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException |
+             IllegalArgumentException | InvocationTargetException e) {
+      e.printStackTrace();
+    }
+    return result;
+  }
+
+}

+ 9 - 0
gradle/libs.versions.toml

@@ -11,6 +11,11 @@ activityCompose = "1.10.1"
 composeBom = "2025.06.00"
 appcompat = "1.7.1"
 
+gson = "2.13.1"
+nanohttpd = "2.3.1"
+navigationCompose = "2.9.0"
+okhttp = "4.12.0"
+webkit = "1.14.0"
 
 [libraries]
 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -31,6 +36,10 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3"
 androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
 
 serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serializationJson" }
+gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
+nanohttpd = { module = "org.nanohttpd:nanohttpd", version.ref = "nanohttpd" }
+okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
+androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
 
 [plugins]
 android-application = { id = "com.android.application", version.ref = "agp" }

+ 1 - 0
library/browser/.gitignore

@@ -0,0 +1 @@
+/build

+ 60 - 0
library/browser/build.gradle.kts

@@ -0,0 +1,60 @@
+plugins {
+  alias(libs.plugins.android.library)
+  alias(libs.plugins.kotlin.android)
+  alias(libs.plugins.kotlin.compose)
+  alias(libs.plugins.kotlin.serialization)
+}
+
+android {
+  namespace = "com.hzliuzhi.applet.browser"
+  compileSdk = 35
+
+  defaultConfig {
+    minSdk = 26
+
+    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+    consumerProguardFiles("consumer-rules.pro")
+  }
+
+  buildTypes {
+    release {
+      isMinifyEnabled = false
+      proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+    }
+  }
+  compileOptions {
+    sourceCompatibility = JavaVersion.VERSION_11
+    targetCompatibility = JavaVersion.VERSION_11
+  }
+  kotlinOptions {
+    jvmTarget = "11"
+  }
+  buildFeatures {
+    compose = true
+  }
+}
+
+dependencies {
+
+  implementation(libs.androidx.core.ktx)
+  implementation(libs.androidx.appcompat)
+  implementation(libs.androidx.activity.compose)
+  implementation(libs.androidx.navigation.compose)
+  implementation(libs.serialization.json)
+  implementation(platform(libs.androidx.compose.bom))
+  implementation(libs.androidx.ui)
+  implementation(libs.androidx.ui.graphics)
+  implementation(libs.androidx.ui.tooling.preview)
+  implementation(libs.androidx.material3)
+  implementation(libs.androidx.webkit)
+  testImplementation(libs.junit)
+  androidTestImplementation(libs.androidx.junit)
+  androidTestImplementation(libs.androidx.espresso.core)
+
+  implementation(project(":core"))
+  implementation(libs.gson)
+  implementation(libs.nanohttpd)
+  implementation(libs.okhttp)
+
+  implementation("com.google.accompanist:accompanist-permissions:0.37.3")
+}

+ 0 - 0
library/browser/consumer-rules.pro


+ 21 - 0
library/browser/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 24 - 0
library/browser/src/androidTest/java/com/hzliuzhi/applet/browser/ExampleInstrumentedTest.kt

@@ -0,0 +1,24 @@
+package com.hzliuzhi.applet.browser
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+  @Test
+  fun useAppContext() {
+    // Context of the app under test.
+    val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+    assertEquals("com.hzliuzhi.applet.browser.test", appContext.packageName)
+  }
+}

+ 16 - 0
library/browser/src/main/AndroidManifest.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+  <uses-permission android:name="android.permission.INTERNET" />
+  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+  <uses-feature android:name="android.hardware.camera" android:required="false" />
+  <uses-permission android:name="android.permission.CAMERA" />
+  <uses-permission android:name="android.permission.RECORD_AUDIO" />
+
+  <application
+      android:networkSecurityConfig="@xml/network_security_config"
+      android:usesCleartextTraffic="true">
+
+  </application>
+</manifest>

+ 30 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/navigation/Route.kt

@@ -0,0 +1,30 @@
+package com.hzliuzhi.applet.browser.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import com.hzliuzhi.applet.browser.ui.KioskScreen
+import com.hzliuzhi.applet.browser.ui.WebScreen
+import com.hzliuzhi.applet.browser.webview.WebContent
+import kotlinx.serialization.Serializable
+
+sealed class BrowserRoute {
+
+  @Serializable
+  data class Web(val url: String? = "") : BrowserRoute()
+
+  @Serializable
+  data class Kiosk(val url: String? = "") : BrowserRoute()
+}
+
+fun NavGraphBuilder.browser(navController: NavController) {
+  composable<BrowserRoute.Web> { backStackEntry ->
+    val route = backStackEntry.toRoute<BrowserRoute.Web>()
+    WebScreen(url = route.url ?: "localhost")
+  }
+  composable<BrowserRoute.Kiosk> { backStackEntry ->
+    val route = backStackEntry.toRoute<BrowserRoute.Kiosk>()
+    KioskScreen(content = WebContent.Url(url = route.url ?: "localhost"))
+  }
+}

+ 30 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/proxy/Config.kt

@@ -0,0 +1,30 @@
+package com.hzliuzhi.applet.browser.proxy
+
+import android.content.Context
+import com.hzliuzhi.applet.browser.R
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class Config(
+  val enabled: Boolean? = false,
+  val pool: Map<String, String>? = emptyMap(),
+) {
+  companion object {
+    fun fromResource(context: Context) = context.resources.let { resources ->
+      val enabled = runCatching { resources.getBoolean(R.bool.browser_http_proxy_enabled) }.getOrNull()
+      val pool = runCatching {
+        resources.getStringArray(R.array.browser_proxy_pool).mapNotNull { item ->
+          item
+            .split("->")
+            .takeIf { it.size == 2 }
+            ?.let { it[0].trim() to it[1].trim() }
+        }.toMap()
+      }.getOrNull()
+      Config(
+        enabled = enabled,
+        pool = pool
+      )
+    }
+  }
+}

+ 60 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/proxy/Server.kt

@@ -0,0 +1,60 @@
+package com.hzliuzhi.applet.browser.proxy
+
+import android.util.Log
+import com.hzliuzhi.applet.browser.request.Client.fetchProxyServer
+import fi.iki.elonen.NanoHTTPD
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.InputStream
+import java.net.ServerSocket
+
+class Server(private val target: String, private val port: Int = 0.port()) : NanoHTTPD("0.0.0.0", port) {
+  private val METHODS_WITH_BODY = setOf(Method.POST, Method.PUT, Method.PATCH)
+
+  override fun serve(session: IHTTPSession): Response {
+    return fetchProxyServer {
+      val path = session.uri.replace(" ", "+")
+      val query = session.queryParameterString.takeUnless { it.isNullOrEmpty() }?.let { "?$it" } ?: ""
+
+      val requestUrl = "$target$path$query"
+      val requestMethod = session.method.name
+      val requestBody: RequestBody? = session.method.takeIf { it in METHODS_WITH_BODY }?.let {
+        val contentType = session.headers["content-type"] ?: "application/octet-stream"
+        val contentLength = session.headers["content-length"]?.toLongOrNull() ?: 0L
+        if (contentLength > 0) {
+          val bodyBytes = session.inputStream.readExactBytes(contentLength.toInt())
+          bodyBytes.toRequestBody(contentType.toMediaTypeOrNull(), 0, contentLength.toInt())
+        } else ByteArray(0).toRequestBody(null, 0, 0)
+      }
+
+      Log.d("log:proxy", "[$requestMethod] 请求发送:$requestUrl")
+      Request.Builder()
+        .url(requestUrl)
+        .method(requestMethod, requestBody)
+        .apply {
+          session.headers.onEach {
+            if (!it.key.equals("host", ignoreCase = true)) addHeader(it.key, it.value)
+          }
+        }.build()
+    }
+  }
+
+  val url by lazy { "http://$hostname:$port" }
+}
+
+private fun Int.port() = runCatching {
+  ServerSocket(this).use { it.localPort }
+}.getOrNull() ?: 0
+
+private fun InputStream.readExactBytes(len: Int): ByteArray {
+  val buffer = ByteArray(len)
+  var read = 0
+  while (read < len) {
+    val r = this.read(buffer, read, len - read)
+    if (r == -1) break
+    read += r
+  }
+  return if (read == len) buffer else buffer.copyOf(read)
+}

+ 108 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/request/Client.kt

@@ -0,0 +1,108 @@
+package com.hzliuzhi.applet.browser.request
+
+import android.util.Log
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import fi.iki.elonen.NanoHTTPD.Response.Status
+import fi.iki.elonen.NanoHTTPD.newChunkedResponse
+import fi.iki.elonen.NanoHTTPD.newFixedLengthResponse
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import fi.iki.elonen.NanoHTTPD.Response as NanoHTTPDResponse
+
+object Client {
+  private const val LARGE_FILE_THRESHOLD = 2 * 1024 * 1024 // 2MB
+
+  suspend fun fetchWithView(request: WebResourceRequest, url: String): WebResourceResponse? {
+    Log.i("log:webview", "[${request.method}] 被拦截的加载请求: ${request.url} -> $url")
+    return fetchWithView {
+      Request.Builder().url(url).apply {
+        request.requestHeaders.onEach { (key, value) ->
+          addHeader(key, value)
+        }
+      }.build()
+    }
+  }
+
+  suspend fun fetchWithView(block: () -> Request) = withContext(Dispatchers.IO) {
+    runCatching {
+      block().let { instance.newCall(it).execute() }.let { response ->
+        val contentType = response.header("Content-Type", "text/html; charset=utf-8")!!
+        val (mimeType, encoding) = contentType.split(";").map { it.trim() }.let {
+          (it.getOrNull(0) ?: "text/html") to (it.getOrNull(1)?.removePrefix("charset=") ?: "utf-8")
+        }
+        WebResourceResponse(
+          mimeType,
+          encoding,
+          response.body?.byteStream()
+        ).apply {
+          responseHeaders = response.headers.toMultimap().mapValues { it.value.joinToString(";") }
+          setStatusCodeAndReasonPhrase(response.code, response.message)
+        }
+      }
+    }.getOrNull()
+  }
+
+  fun fetchProxyServer(block: () -> Request): NanoHTTPDResponse {
+    val threshold = LARGE_FILE_THRESHOLD
+    block().let { instance.newCall(it).execute() }.let { response ->
+      val responseCode = response.code
+      val contentType = response.header("Content-Type", "application/octet-stream")
+      val contentLength = response.body?.contentLength() ?: -1L
+      Log.d("log:proxy", "[${response.request.method}] 请求响应:${response.request.url} ($responseCode, $contentType, length: $contentLength)")
+
+      val res = if (contentLength > threshold) {
+        // 大文件:写入临时文件并返回流,流关闭时自动删除临时文件
+        val tempFile = File.createTempFile("proxy_", ".tmp")
+        response.body?.byteStream()?.use { input ->
+          FileOutputStream(tempFile).use { output ->
+            input.copyTo(output)
+          }
+        }
+        // 包装 FileInputStream,使其关闭时自动删除临时文件
+        val autoDeleteStream = object : FileInputStream(tempFile) {
+          override fun close() {
+            super.close()
+            tempFile.apply { if (exists()) delete() }
+          }
+        }
+        // 返回响应,使用 chunked 方式
+        newChunkedResponse(
+          Status.lookup(response.code) ?: Status.OK,
+          contentType,
+          autoDeleteStream
+        )
+      } else {
+        val bodyBytes = response.body?.bytes() ?: ByteArray(0)
+        newFixedLengthResponse(
+          Status.lookup(response.code) ?: Status.OK,
+          contentType,
+          ByteArrayInputStream(bodyBytes),
+          bodyBytes.size.toLong()
+        )
+      }
+
+      // 关闭 OkHttp Response
+      response.close()
+
+      // 转发响应头
+      for ((key, value) in response.headers) {
+        key.takeUnless { it.equals("Content-Length", ignoreCase = true) }?.also {
+          res.addHeader(it, value)
+        }
+      }
+
+      return res;
+    }
+  }
+
+  private val instance by lazy {
+    OkHttpClient.Builder().apply { }.build()
+  }
+}

+ 44 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/KioskScreen.kt

@@ -0,0 +1,44 @@
+package com.hzliuzhi.applet.browser.ui
+
+
+import android.view.ViewGroup.LayoutParams
+import android.widget.FrameLayout
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.zIndex
+import com.hzliuzhi.applet.browser.ui.components.ProgressBar
+import com.hzliuzhi.applet.browser.webview.WebContent
+import com.hzliuzhi.applet.browser.webview.WebView
+import com.hzliuzhi.applet.browser.webview.WebViewController
+import com.hzliuzhi.applet.browser.webview.rememberWebViewController
+import kotlinx.coroutines.flow.asStateFlow
+
+@Composable
+fun KioskScreen(
+  content: WebContent,
+  modifier: Modifier = Modifier,
+  controller: WebViewController = rememberWebViewController(),
+) {
+  val state by controller.state.asStateFlow().collectAsState()
+
+  BoxWithConstraints(
+    modifier = modifier.fillMaxSize(),
+  ) {
+    val layoutParams = FrameLayout.LayoutParams(
+      if (constraints.hasFixedWidth) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT,
+      if (constraints.hasFixedHeight) LayoutParams.MATCH_PARENT else LayoutParams.WRAP_CONTENT,
+    )
+    WebView(
+      layoutParams = layoutParams,
+      controller = controller,
+      onCreated = { controller.connected(it, content) },
+      onDispose = { controller.unconnected(it) }
+    )
+
+    ProgressBar(state.load, modifier = Modifier.zIndex(1f))
+  }
+}

+ 89 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/WebScreen.kt

@@ -0,0 +1,89 @@
+package com.hzliuzhi.applet.browser.ui
+
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.hzliuzhi.applet.browser.webview.WebContent
+import com.hzliuzhi.applet.browser.webview.rememberWebViewController
+import kotlinx.coroutines.flow.asStateFlow
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WebScreen(
+  url: String,
+  modifier: Modifier = Modifier,
+) {
+  val controller = rememberWebViewController()
+  val state by controller.state.asStateFlow().collectAsState()
+
+  Scaffold(
+    topBar = {
+      TopAppBar(
+        title = {
+          Row(
+            verticalAlignment = Alignment.CenterVertically
+          ) {
+            state.icon?.let { bitmap ->
+              Image(
+                bitmap = bitmap.asImageBitmap(),
+                contentDescription = "图标",
+                modifier = Modifier
+                  .height(32.dp) // 与 Row 高度一致
+                  .width(32.dp)  // 保持正方形或根据实际图标比例调整
+              )
+              Spacer(modifier = Modifier.width(8.dp)) // icon 和 title 间隔
+            }
+            state.title?.let {
+              Text(
+                text = it,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis, // 超出部分省略号
+                // 如果你想要滚动而不是省略号,可以这样:
+                modifier = Modifier.horizontalScroll(rememberScrollState())
+              )
+              Spacer(modifier = Modifier.width(8.dp)) // icon 和 title 间隔
+            }
+          }
+        },
+        navigationIcon = {
+          if (controller.navigator.canGoBack) {
+            IconButton(onClick = { controller.navigator.back() }) {
+              Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
+            }
+          }
+        }
+      )
+    },
+    modifier = modifier,
+    contentWindowInsets = WindowInsets(0),
+  ) { padding ->
+    KioskScreen(
+      content = WebContent.Url(url = url),
+      controller = controller,
+      modifier = Modifier.padding(padding)
+    )
+  }
+}

+ 50 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/components/ProgressBar.kt

@@ -0,0 +1,50 @@
+package com.hzliuzhi.applet.browser.ui.components
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProgressIndicatorDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.hzliuzhi.applet.browser.webview.WebState.LoadState
+
+
+@Composable
+fun ProgressBar(
+  state: LoadState,
+  modifier: Modifier = Modifier,
+) {
+  val animatedProgress by animateFloatAsState(
+    targetValue = when (state) {
+      is LoadState.Loading -> state.progress
+      is LoadState.Started -> 0.0f
+      is LoadState.Finished -> 1.0f
+      else -> -1.0f
+    },
+    animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec
+  )
+  if (animatedProgress > 0 && animatedProgress < 1) LinearProgressIndicator(
+    progress = { animatedProgress },
+    strokeCap = StrokeCap.Butt,
+    gapSize = 0.dp,
+    color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
+    trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
+    modifier = modifier
+      .fillMaxWidth()
+      .height(4.dp),
+    drawStopIndicator = {},
+  )
+}
+
+
+@Preview(showBackground = true)
+@Composable
+fun ProgressBarPreview() {
+  ProgressBar(state = LoadState.Loading(0.5f))
+}

+ 8 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebContent.kt

@@ -0,0 +1,8 @@
+package com.hzliuzhi.applet.browser.webview
+
+sealed class WebContent {
+  data class Url(
+    val url: String,
+    val additionalHttpHeaders: Map<String, String> = emptyMap(),
+  ) : WebContent()
+}

+ 69 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebProxy.kt

@@ -0,0 +1,69 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.net.Uri
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import androidx.core.net.toUri
+import com.hzliuzhi.applet.browser.proxy.Config
+import com.hzliuzhi.applet.browser.proxy.Server
+import com.hzliuzhi.applet.browser.request.Client
+import kotlinx.coroutines.runBlocking
+import java.util.concurrent.ConcurrentHashMap
+
+class WebProxy() {
+  private var enabled: Boolean = false
+  private var pool: MutableMap<String, String> = mutableMapOf()
+
+  private val servers = ConcurrentHashMap<String, Server>()
+
+
+  internal fun WebView.handlerProxy() {
+    val config = Config.fromResource(context.applicationContext)
+    enabled = config.enabled ?: false
+    pool = config.pool?.toMutableMap() ?: mutableMapOf()
+  }
+
+  fun loadUrl(url: String): String {
+    return url.takeUnless { enabled && url.startsWith("http://") } ?: run {
+      val origin = url.toUri().origin
+      val server = servers[origin] ?: Server(origin).also {
+        it.start()
+        servers[origin] = it
+      }
+      url.replaceFirst(origin, server.url.toUri().origin)
+    }
+  }
+
+  fun fetchWithView(request: WebResourceRequest): WebResourceResponse? {
+    val targetUrl = request.url.toString()
+    val proxyUrl = pool.entries
+      .firstOrNull { targetUrl.startsWith(it.key) }
+      ?.let { targetUrl.replaceFirst(it.key, it.value) }
+
+    return proxyUrl?.let { runBlocking { Client.fetchWithView(request, it) } }
+  }
+
+  fun allStop() {
+    servers.onEach { it.value.stop() }
+  }
+}
+
+
+private val Uri.origin: String
+  get() = run {
+    val scheme = this.scheme
+    val hostname = when (this.host) {
+      "0.0.0.0",
+      "127.0.0.0",
+        -> "localhost"
+
+      else -> host
+    }
+    val port = this.port.takeIf { it > 0 } ?: when (scheme) {
+      "http" -> 80
+      "https" -> 443
+      else -> 0
+    }
+    return "$scheme://$hostname:$port"
+  }

+ 44 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebSettings.kt

@@ -0,0 +1,44 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.annotation.SuppressLint
+import android.content.pm.PackageManager
+import android.os.Build
+import android.webkit.WebSettings
+import android.webkit.WebView
+import androidx.webkit.WebSettingsCompat
+import com.hzliuzhi.applet.core.util.SystemPropertiesProxy
+
+
+@SuppressLint("SetJavaScriptEnabled", "RequiresFeature")
+internal fun WebSettings.applyDefaultSettings() {
+  javaScriptEnabled = true
+  domStorageEnabled = true
+
+  setSupportZoom(false)
+  displayZoomControls = false
+  builtInZoomControls = false
+
+  useWideViewPort = true
+  loadWithOverviewMode = true
+
+  mediaPlaybackRequiresUserGesture = false
+
+  // 应用自定义设置
+  WebSettingsCompat.setSafeBrowsingEnabled(this, false)
+}
+
+internal fun WebView.applyUserAgent() {
+  runCatching {
+    val info = context.applicationContext.let {
+      it.packageManager.getPackageInfo(it.packageName, PackageManager.GET_META_DATA)
+    }
+    val tag = info.applicationInfo?.metaData?.getString("build_type_tag") ?: "browser"
+    val packageName = info.packageName
+    val versionName = info.versionName
+    val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong()
+
+    val serial = SystemPropertiesProxy.getDeviceSN()
+
+    "Six/applet ($tag; $packageName; Build/$versionName+$versionCode) Aio/$versionName SN/$serial"
+  }.getOrNull()?.also { ua -> settings.userAgentString += " $ua" }
+}

+ 17 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebState.kt

@@ -0,0 +1,17 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.graphics.Bitmap
+
+data class WebState(
+  val title: String? = null,
+  val icon: Bitmap? = null,
+  val load: LoadState = LoadState.Initialized,
+) {
+  sealed interface LoadState {
+    data object Initialized : LoadState
+    data object Started : LoadState
+    data object Finished : LoadState
+    data class Loading(val progress: Float) : LoadState
+  }
+}
+

+ 53 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebView.kt

@@ -0,0 +1,53 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.webkit.WebView
+import android.widget.FrameLayout
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import kotlinx.coroutines.CoroutineScope
+
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+fun WebView(
+  layoutParams: FrameLayout.LayoutParams,
+  modifier: Modifier = Modifier,
+  controller: WebViewController = rememberWebViewController(),
+  onCreated: (WebView) -> Unit = {},
+  onDispose: (WebView) -> Unit = {},
+  viewClient: WrapperWebViewClient = remember { WrapperWebViewClient() },
+  chromeClient: WrapperWebChromeClient = remember { WrapperWebChromeClient() },
+  factory: ((Context) -> WebView)? = null,
+) {
+  viewClient.controller = controller
+  chromeClient.controller = controller
+
+
+  BackHandler(controller.navigator.canGoBack) {
+    controller.navigator.back()
+  }
+
+  WebViewPermission(controller)
+
+  AndroidView(
+    factory = { context ->
+      (factory?.invoke(context) ?: WebView(context)).apply {
+        this.layoutParams = layoutParams
+        this.webViewClient = viewClient
+        this.webChromeClient = chromeClient
+      }.also(onCreated)
+    },
+    onRelease = { onDispose(it) },
+    modifier = modifier,
+  )
+}
+
+@Composable
+fun rememberWebViewController(coroutineScope: CoroutineScope = rememberCoroutineScope()) = remember(coroutineScope) {
+  WebViewController(coroutineScope = coroutineScope)
+}

+ 74 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewBridge.kt

@@ -0,0 +1,74 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.annotation.SuppressLint
+import android.util.Log
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+import com.hzliuzhi.applet.core.SharedFlowHub
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.withContext
+import java.io.InputStreamReader
+
+class WebViewBridge(private val coroutineScope: CoroutineScope) {
+  data class Message(
+    val type: String,
+    val payload: JsonElement = Gson().toJsonTree(""),
+    val callbackId: String? = null,
+  ) {
+    companion object {
+      fun fromJson(json: String) = try {
+        Gson().fromJson(json, Message::class.java)
+      } catch (_: Throwable) {
+        null
+      }
+
+      fun toJson(message: Message) = try {
+        Gson().toJson(message)
+      } catch (_: Throwable) {
+        null
+      }
+    }
+  }
+
+
+  private var lastScriptText: String? = null
+  fun inject(webview: WebView) {
+    val script = lastScriptText.takeUnless { it == null } ?: runCatching {
+      webview.context.applicationContext.assets.open("browser/bridge.js").use { stream ->
+        InputStreamReader(stream).use { reader ->
+          reader.readText()
+        }
+      }.also { lastScriptText = it }
+    }.getOrNull()
+    script?.also { webview.evaluateJavascript(it, null) }
+  }
+
+
+  private val messages = MutableSharedFlow<Message>(replay = 1)
+
+  @SuppressLint("JavascriptInterface")
+  internal suspend fun WebView.handleBridge(): Nothing = withContext(Dispatchers.Main) {
+    addJavascriptInterface(this@WebViewBridge, "AndroidBridge")
+    messages.collect {
+      Log.d("log:bridge", "接收到的事件: ${it.type}")
+      SharedFlowHub.emit(
+        SharedFlowHub.Event(
+        type = it.type,
+        payload = it.payload,
+        callback = {}
+      ))
+    }
+  }
+
+
+  @JavascriptInterface
+  fun postMessage(string: String) {
+    Message.fromJson(string)?.also {
+      messages.tryEmit(it)
+    }
+  }
+}

+ 56 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewController.kt

@@ -0,0 +1,56 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.util.Log
+import android.webkit.PermissionRequest
+import android.webkit.WebView
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+
+class WebViewController(
+  private val coroutineScope: CoroutineScope,
+) {
+  val proxy = WebProxy()
+  val navigator = WebViewNavigator(coroutineScope) { url -> proxy.loadUrl(url) }
+  val bridge = WebViewBridge(coroutineScope)
+
+  internal var webview by mutableStateOf<WebView?>(null)
+  var state = MutableStateFlow(WebState())
+    internal set
+
+
+  val permissionRequest = MutableStateFlow<PermissionRequest?>(null)
+
+  fun connected(webview: WebView, content: WebContent) {
+    this.webview = webview
+
+    val context = webview.context.applicationContext
+    val appInfo = context.packageManager.getApplicationInfo(context.packageName, android.content.pm.PackageManager.GET_META_DATA)
+    val myParam = appInfo.metaData.getString("build_type_tag")
+
+    Log.d("log:build_type_tag", myParam.toString())
+
+    webview.applyUserAgent()
+    webview.settings.applyDefaultSettings()
+    coroutineScope.launch {
+      with(bridge) {
+        webview.handleBridge()
+      }
+    }
+    coroutineScope.launch {
+      with(proxy) { webview.handlerProxy() }
+      with(navigator) {
+        webview.load(content)
+        webview.handleNavigation()
+      }
+    }
+  }
+
+  fun unconnected(webview: WebView) {
+    proxy.allStop()
+  }
+}
+

+ 72 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewNavigator.kt

@@ -0,0 +1,72 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.webkit.WebView
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class WebViewNavigator(
+  private val coroutineScope: CoroutineScope,
+  private val proxy: ((String) -> String)? = null,
+) {
+  private sealed interface Event {
+    data object Back : Event
+    data object Forward : Event
+    data object Stop : Event
+
+    data object Reload : Event
+    data class LoadUrl(
+      val url: String,
+      val additionalHttpHeaders: Map<String, String>? = emptyMap(),
+    ) : Event
+  }
+
+  private val event = MutableSharedFlow<Event>(replay = 1)
+
+  var canGoBack: Boolean by mutableStateOf(false)
+    internal set
+  var canGoForward: Boolean by mutableStateOf(false)
+    internal set
+
+  internal suspend fun WebView.handleNavigation(): Nothing = withContext(Dispatchers.Main) {
+    event.collect {
+      when (it) {
+        Event.Back -> goBack()
+        Event.Forward -> goForward()
+        Event.Reload -> reload()
+        Event.Stop -> stopLoading()
+        is Event.LoadUrl -> loadUrl(it.url, it.additionalHttpHeaders ?: emptyMap())
+        else -> {}
+      }
+    }
+  }
+
+  internal fun WebView.load(content: WebContent) = this@WebViewNavigator.load(content)
+
+
+  fun back() {
+    coroutineScope.launch { event.emit(Event.Back) }
+  }
+
+  fun stop() {
+    coroutineScope.launch { event.emit(Event.Stop) }
+  }
+
+  fun reload() {
+    coroutineScope.launch { event.emit(Event.Reload) }
+  }
+
+  fun load(content: WebContent) {
+    coroutineScope.launch {
+      when (content) {
+        is WebContent.Url -> (proxy?.invoke(content.url) ?: content.url).let { event.emit(Event.LoadUrl(it, content.additionalHttpHeaders)) }
+        else -> {}
+      }
+    }
+  }
+}

+ 72 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewPermission.kt

@@ -0,0 +1,72 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.Manifest
+import android.webkit.PermissionRequest
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun WebViewPermission(
+  controller: WebViewController,
+  onGranted: (PermissionRequest) -> Unit = { it.grant(it.resources) },
+  onDenied: ((PermissionRequest) -> Unit)? = { it.deny() },
+) {
+  val request by controller.permissionRequest.collectAsState()
+  var permissions = remember(request) {
+    request?.resources?.mapNotNull(::mapWebResourceToPermission) ?: emptyList()
+  }
+  var hasRequested by remember { mutableStateOf(false) }
+
+  val states = permissions.map { rememberPermissionState(it) }
+
+  fun isAllGranted() = permissions.all { key -> states.find { it.permission == key }?.status?.isGranted.orFalse() }
+
+  // 监听权限变化并触发权限请求
+  LaunchedEffect(states.map { it.status.isGranted }) {
+    if (request == null || permissions.isEmpty()) return@LaunchedEffect
+
+    if (!hasRequested) {
+      states.filter { !it.status.isGranted }
+        .onEach { it.launchPermissionRequest() }
+        .takeUnless { it.isEmpty() }
+        ?.also {
+          hasRequested = true
+          return@LaunchedEffect
+        }
+    }
+
+    when {
+      isAllGranted() -> {
+        onGranted(request!!)
+        controller.permissionRequest.value = null
+        hasRequested = false
+      }
+
+      states.any { !it.status.isGranted } -> {
+        onDenied?.invoke(request!!)
+        controller.permissionRequest.value = null
+        hasRequested = false
+      }
+    }
+  }
+}
+
+/**
+ * WebView 权限资源类型到 Android 权限的映射
+ */
+fun mapWebResourceToPermission(resource: String): String? = when (resource) {
+  PermissionRequest.RESOURCE_AUDIO_CAPTURE -> Manifest.permission.RECORD_AUDIO
+  PermissionRequest.RESOURCE_VIDEO_CAPTURE -> Manifest.permission.CAMERA
+  else -> null
+}
+
+private fun Boolean?.orFalse() = this ?: false

+ 39 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WrapperWebChromeClient.kt

@@ -0,0 +1,39 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.graphics.Bitmap
+import android.util.Log
+import android.webkit.PermissionRequest
+import android.webkit.WebChromeClient
+import android.webkit.WebView
+import kotlinx.coroutines.flow.update
+
+open class WrapperWebChromeClient : WebChromeClient() {
+  open lateinit var controller: WebViewController
+    internal set
+
+  override fun onReceivedTitle(view: WebView?, title: String?) {
+    super.onReceivedTitle(view, title)
+    controller.state.update { state ->
+      state.copy(title = title)
+    }
+  }
+
+  override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
+    super.onReceivedIcon(view, icon)
+    controller.state.update { state ->
+      state.copy(icon = icon)
+    }
+  }
+
+  override fun onProgressChanged(view: WebView?, newProgress: Int) {
+    super.onProgressChanged(view, newProgress)
+    controller.state.takeUnless { it.value.load is WebState.LoadState.Finished }?.update { state ->
+      val progress = newProgress / 100.00f
+      state.copy(load = if (progress == 1.0f) WebState.LoadState.Finished else WebState.LoadState.Loading(progress))
+    }
+  }
+
+  override fun onPermissionRequest(request: PermissionRequest?) {
+    controller.permissionRequest.value = request
+  }
+}

+ 61 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WrapperWebViewClient.kt

@@ -0,0 +1,61 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.graphics.Bitmap
+import android.util.Log
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import kotlinx.coroutines.flow.update
+
+open class WrapperWebViewClient : WebViewClient() {
+  open lateinit var controller: WebViewController
+    internal set
+
+  private val finished = mutableSetOf<String>()
+
+
+  override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+    super.onPageStarted(view, url, favicon)
+    url?.also { finished.remove(it) }
+    controller.state.update { state ->
+      state.copy(
+        icon = favicon,
+        load = WebState.LoadState.Started,
+      )
+    }
+  }
+
+  override fun onPageFinished(view: WebView?, url: String?) {
+    super.onPageFinished(view, url)
+    // 只处理首次次加载的 url
+    url?.takeUnless { finished.add(it) }?.also {
+      view?.also { controller.bridge.inject(it) }
+    }
+    controller.state.apply {
+      value.load.takeUnless { it is WebState.LoadState.Loading }?.also {
+        update { state -> state.copy(load = WebState.LoadState.Finished) }
+      }
+    }
+  }
+
+  override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
+    super.doUpdateVisitedHistory(view, url, isReload)
+    view?.apply {
+      controller.navigator.canGoBack = canGoBack()
+      controller.navigator.canGoForward = canGoForward()
+    }
+  }
+
+  override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
+    super.onReceivedError(view, request, error)
+    error?.also {
+      Log.d("log:webview", "加载错误: ${it.description}(${it.errorCode})")
+    }
+  }
+
+  override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
+    return request?.let { controller.proxy.fetchWithView(it) } ?: super.shouldInterceptRequest(view, request)
+  }
+}

+ 5 - 0
library/browser/src/main/res/values/proxy.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="browser_http_proxy_enabled">false</bool>
+  <string-array name="browser_proxy_pool" />
+</resources>

+ 13 - 0
library/browser/src/main/res/xml/network_security_config.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<network-security-config xmlns:tools="http://schemas.android.com/tools">
+  <base-config
+      cleartextTrafficPermitted="true"
+      tools:ignore="InsecureBaseConfiguration">
+    <trust-anchors>
+      <certificates src="system" />
+      <certificates
+          src="user"
+          tools:ignore="AcceptsUserCertificates" />
+    </trust-anchors>
+  </base-config>
+</network-security-config>

+ 17 - 0
library/browser/src/test/java/com/hzliuzhi/applet/browser/ExampleUnitTest.kt

@@ -0,0 +1,17 @@
+package com.hzliuzhi.applet.browser
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+  @Test
+  fun addition_isCorrect() {
+    assertEquals(4, 2 + 2)
+  }
+}

+ 1 - 0
settings.gradle.kts

@@ -22,3 +22,4 @@ dependencyResolutionManagement {
 rootProject.name = "Six-applet.Container"
 include(":app")
 include(":core")
+include(":library:browser")