Ver Fonte

Merge branch 'release/2.1.1'

cc12458 há 2 semanas atrás
pai
commit
1388138ebf
100 ficheiros alterados com 3716 adições e 53 exclusões
  1. 2 1
      .gitignore
  2. 6 0
      .idea/gradle.xml
  3. 1 0
      .idea/inspectionProfiles/Project_Default.xml
  4. 0 1
      .idea/misc.xml
  5. 62 2
      app/build.gradle.kts
  6. 6 0
      app/src/aio-CQ/res/values/application.xml
  7. 12 0
      app/src/aio-CQ/res/values/browser_proxy.xml
  8. 5 0
      app/src/aio-CQ/res/values/pulse.xml
  9. 6 0
      app/src/aio-test/res/values/application.xml
  10. 12 0
      app/src/aio-test/res/values/browser_proxy.xml
  11. 5 0
      app/src/aio-test/res/values/pulse.xml
  12. 64 0
      app/src/aio/assets/browser/bridge.js
  13. 8 0
      app/src/aio/res/values/application.xml
  14. 12 0
      app/src/aio/res/values/browser_proxy.xml
  15. 5 0
      app/src/aio/res/values/pulse.xml
  16. 6 3
      app/src/main/AndroidManifest.xml
  17. 110 0
      app/src/main/java/com/hzliuzhi/applet/container/AndroidActivity.kt
  18. 60 33
      app/src/main/java/com/hzliuzhi/applet/container/MainActivity.kt
  19. 38 0
      app/src/main/java/com/hzliuzhi/applet/container/navigation/Host.kt
  20. 22 0
      app/src/main/java/com/hzliuzhi/applet/container/navigation/Route.kt
  21. 18 0
      app/src/main/java/com/hzliuzhi/applet/container/navigation/RouteExtra.kt
  22. 26 0
      app/src/main/java/com/hzliuzhi/applet/container/ui/HomeScreen.kt
  23. 6 0
      app/src/main/res/values/application.xml
  24. 0 3
      app/src/main/res/values/strings.xml
  25. 0 5
      app/src/main/res/values/themes.xml
  26. 6 0
      app/src/pda-test/res/values/application.xml
  27. 10 0
      app/src/pda-test/res/values/browser_update.xml
  28. 6 0
      app/src/pda/res/values/application.xml
  29. 10 0
      app/src/pda/res/values/browser_update.xml
  30. 1 0
      build.gradle.kts
  31. 1 0
      core/.gitignore
  32. 49 0
      core/build.gradle.kts
  33. 0 0
      core/consumer-rules.pro
  34. 21 0
      core/proguard-rules.pro
  35. 24 0
      core/src/androidTest/java/com/hzliuzhi/applet/core/ExampleInstrumentedTest.kt
  36. 4 0
      core/src/main/AndroidManifest.xml
  37. 7 0
      core/src/main/java/com/hzliuzhi/applet/core/shared/Event.kt
  38. 21 0
      core/src/main/java/com/hzliuzhi/applet/core/shared/Payload.kt
  39. 35 0
      core/src/main/java/com/hzliuzhi/applet/core/shared/SharedFlowHub.kt
  40. 1 1
      core/src/main/java/com/hzliuzhi/applet/core/theme/Color.kt
  41. 2 3
      core/src/main/java/com/hzliuzhi/applet/core/theme/Theme.kt
  42. 1 1
      core/src/main/java/com/hzliuzhi/applet/core/theme/Type.kt
  43. 12 0
      core/src/main/java/com/hzliuzhi/applet/core/util/Resources.kt
  44. 98 0
      core/src/main/java/com/hzliuzhi/applet/core/util/SystemPropertiesProxy.java
  45. 0 0
      core/src/main/res/values/colors.xml
  46. 4 0
      core/src/main/res/values/themes.xml
  47. 17 0
      core/src/test/java/com/hzliuzhi/applet/core/ExampleUnitTest.kt
  48. 1 0
      gradle.properties
  49. 22 0
      gradle/libs.versions.toml
  50. 1 0
      library/browser/.gitignore
  51. 63 0
      library/browser/build.gradle.kts
  52. 0 0
      library/browser/consumer-rules.pro
  53. 21 0
      library/browser/proguard-rules.pro
  54. 24 0
      library/browser/src/androidTest/java/com/hzliuzhi/applet/browser/ExampleInstrumentedTest.kt
  55. 31 0
      library/browser/src/main/AndroidManifest.xml
  56. 117 0
      library/browser/src/main/assets/browser/bridge.js
  57. 38 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/navigation/Route.kt
  58. 14 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/print/Print.kt
  59. 151 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/print/PrintEventHandler.kt
  60. 30 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/proxy/Config.kt
  61. 60 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/proxy/Server.kt
  62. 108 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/request/Client.kt
  63. 55 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/KioskScreen.kt
  64. 275 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/UpdateScreen.kt
  65. 89 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/WebScreen.kt
  66. 149 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/components/NetworkMask.kt
  67. 50 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/components/ProgressBar.kt
  68. 19 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateController.kt
  69. 34 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdatePackage.kt
  70. 66 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateSource.kt
  71. 12 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateState.kt
  72. 97 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateUtil.kt
  73. 189 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/WebViewUpdate.kt
  74. 8 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebContent.kt
  75. 69 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebProxy.kt
  76. 44 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebSettings.kt
  77. 17 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebState.kt
  78. 54 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebView.kt
  79. 100 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewBridge.kt
  80. 50 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewController.kt
  81. 171 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewFileChooser.kt
  82. 87 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewNavigator.kt
  83. 72 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewPermission.kt
  84. 47 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WrapperWebChromeClient.kt
  85. 61 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WrapperWebViewClient.kt
  86. 5 0
      library/browser/src/main/res/values/proxy.xml
  87. 8 0
      library/browser/src/main/res/values/update.xml
  88. 4 0
      library/browser/src/main/res/xml/file_paths.xml
  89. 13 0
      library/browser/src/main/res/xml/network_security_config.xml
  90. 17 0
      library/browser/src/test/java/com/hzliuzhi/applet/browser/ExampleUnitTest.kt
  91. 1 0
      library/device/pulse/.gitignore
  92. 58 0
      library/device/pulse/build.gradle.kts
  93. 0 0
      library/device/pulse/consumer-rules.pro
  94. 21 0
      library/device/pulse/proguard-rules.pro
  95. 24 0
      library/device/pulse/src/androidTest/java/com/hzliuzhi/applet/device/pulse/ExampleInstrumentedTest.kt
  96. 4 0
      library/device/pulse/src/main/AndroidManifest.xml
  97. 57 0
      library/device/pulse/src/main/java/com/hzliuzhi/applet/device/pulse/PulseEventHandler.kt
  98. 95 0
      library/device/pulse/src/main/java/com/hzliuzhi/applet/device/pulse/PulseResult.kt
  99. 36 0
      library/device/pulse/src/main/java/com/hzliuzhi/applet/device/pulse/util/TaiYiResult.kt
  100. 55 0
      library/device/pulse/src/main/java/com/hzliuzhi/applet/device/pulse/util/TaiYiUtil.kt

+ 2 - 1
.gitignore

@@ -13,4 +13,5 @@
 .externalNativeBuild
 .cxx
 local.properties
-.kotlin/errors/
+.kotlin/errors/
+**/assets/browser/**/*.apk

+ 6 - 0
.idea/gradle.xml

@@ -11,6 +11,12 @@
           <set>
             <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" />
+            <option value="$PROJECT_DIR$/library/device" />
+            <option value="$PROJECT_DIR$/library/device/pulse" />
+            <option value="$PROJECT_DIR$/library/device/scanner" />
           </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">

+ 62 - 2
app/build.gradle.kts

@@ -1,7 +1,18 @@
+import com.android.build.gradle.internal.api.BaseVariantOutputImpl
+import java.text.SimpleDateFormat
+import java.util.Date
+
+fun getVersionCode(): Int {
+  val dateFormat = SimpleDateFormat("yyyyMMdd")
+  val versionCodeStr = dateFormat.format(Date())
+  return versionCodeStr.toInt()
+}
+
 plugins {
   alias(libs.plugins.android.application)
   alias(libs.plugins.kotlin.android)
   alias(libs.plugins.kotlin.compose)
+  alias(libs.plugins.kotlin.serialization)
 }
 
 android {
@@ -12,8 +23,8 @@ android {
     applicationId = "com.hzliuzhi.applet.container"
     minSdk = 26
     targetSdk = 35
-    versionCode = 1
-    versionName = "1.0"
+    versionCode = getVersionCode()
+    versionName = "2.0.0"
 
     testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
   }
@@ -23,7 +34,48 @@ android {
       isMinifyEnabled = false
       proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
     }
+    create("aio") {
+      initWith(getByName("debug"))
+      matchingFallbacks += listOf("debug")
+    }
+    create("aio-test") {
+      initWith(getByName("aio"))
+      matchingFallbacks += listOf("aio", "debug")
+    }
+    create("aio-CQ") {
+      initWith(getByName("aio-test"))
+      matchingFallbacks += listOf("aio-test", "aio", "debug")
+    }
+
+    create("pda") {
+      initWith(getByName("debug"))
+      matchingFallbacks += listOf("debug")
+    }
+
+    create("pda-test") {
+      initWith(getByName("pda"))
+      matchingFallbacks += listOf("pda", "debug")
+    }
+  }
+
+  androidResources {
+    noCompress.add("apk")
   }
+
+  applicationVariants.all {
+    outputs.all {
+      val appName = "six" // 你的自定义名称
+      val buildType = buildType.name
+      val versionName = versionName
+      val versionCode = versionCode
+
+      val fileName = "${appName}.${buildType}-${versionName}-${versionCode}.apk"
+      (this as BaseVariantOutputImpl).apply {
+        outputFileName = fileName
+      }
+    }
+  }
+
   compileOptions {
     sourceCompatibility = JavaVersion.VERSION_11
     targetCompatibility = JavaVersion.VERSION_11
@@ -41,6 +93,8 @@ dependencies {
   implementation(libs.androidx.core.ktx)
   implementation(libs.androidx.lifecycle.runtime.ktx)
   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)
@@ -53,4 +107,10 @@ dependencies {
   androidTestImplementation(libs.androidx.ui.test.junit4)
   debugImplementation(libs.androidx.ui.tooling)
   debugImplementation(libs.androidx.ui.test.manifest)
+
+  implementation(project(":core"))
+  implementation(project(":library:browser"))
+  implementation(project(":library:device:pulse"))
+  implementation(project(":library:device:scanner"))
+  implementation(libs.gson)
 }

+ 6 - 0
app/src/aio-CQ/res/values/application.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="app_id">AIO</string>
+  <string name="app_name">中医健康辨识仪(区域版.CQ)</string>
+  <string name="app_screen">browser/kiosk?url=http://10.227.137.3:98/aio/screen</string>
+</resources>

+ 12 - 0
app/src/aio-CQ/res/values/browser_proxy.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="browser_http_proxy_enabled">true</bool>
+  <string-array name="browser_proxy_pool">
+    <item>https://hybrid.reborn-tech.com -> http://10.227.137.3:98/mz/hybrid</item>
+    <item>https://api.reborn-tech.com -> http://10.227.137.2/mz/api</item>
+    <item>https://taiyi.oss-accelerate.aliyuncs.com -> http://10.227.137.2/mz/oss</item>
+    <item>https://taiyi.oss-cn-beijing.aliyuncs.com -> http://10.227.137.2/mz/oss</item>
+    <item>https://oss-accelerate.aliyuncs.com/taiyi -> http://10.227.137.2/mz/oss</item>
+    <item>https://oss-cn-beijing.aliyuncs.com/taiyi -> http://10.227.137.2/mz/oss</item>
+  </string-array>
+</resources>

+ 5 - 0
app/src/aio-CQ/res/values/pulse.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="taiyi_skip_report">true</bool>
+  <integer name="taiyi_delay_disconnect">300</integer>
+</resources>

+ 6 - 0
app/src/aio-test/res/values/application.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="app_id">AIO</string>
+  <string name="app_name">中医健康辨识仪(测试版)</string>
+  <string name="app_screen">browser/kiosk?url=https://wx.hzliuzhi.com:4433/aio/</string>
+</resources>

+ 12 - 0
app/src/aio-test/res/values/browser_proxy.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="browser_http_proxy_enabled">true</bool>
+  <string-array name="browser_proxy_pool">
+    <item>https://hybrid.reborn-tech.com -> https://wx.hzliuzhi.com/mz/hybrid</item>
+    <item>https://api.reborn-tech.com -> https://wx.hzliuzhi.com/mz/api</item>
+    <item>https://taiyi.oss-accelerate.aliyuncs.com -> https://wx.hzliuzhi.com/mz/oss</item>
+    <item>https://taiyi.oss-cn-beijing.aliyuncs.com -> https://wx.hzliuzhi.com/mz/oss</item>
+    <item>https://oss-accelerate.aliyuncs.com/taiyi -> https://wx.hzliuzhi.com/mz/oss</item>
+    <item>https://oss-cn-beijing.aliyuncs.com/taiyi -> https://wx.hzliuzhi.com/mz/oss</item>
+  </string-array>
+</resources>

+ 5 - 0
app/src/aio-test/res/values/pulse.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="taiyi_skip_report">true</bool>
+  <integer name="taiyi_delay_disconnect">300</integer>
+</resources>

+ 64 - 0
app/src/aio/assets/browser/bridge.js

@@ -0,0 +1,64 @@
+class Bridge extends EventTarget {
+  #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}`);
+    try {
+      const { type, callbackId, payload }  = JSON.parse(message);
+      if (callbackId) {
+        const { resolve, reject } = this.#pool.get(callbackId) ?? {};
+        this.#pool.delete(callbackId);
+        if (payload.code === 0) resolve(payload.data)
+        else reject(payload.message)
+      } else {
+        event = new CustomEvent(type, { detail: payload });
+        super.dispatchEvent(event);
+      }
+    } catch (e) {
+      console.log('log:bridge:js', `[analysis] 解析消息错误: ${e.message}`);
+      throw e;
+    }
+  }
+
+  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', `[dispatch] 发送消息: ${message}`);
+  }
+}
+
+window['Bridge'] = Bridge;
+window['bridge'] = Bridge.getInstance();
+
+window.print = Bridge.print.bind(Bridge);

+ 8 - 0
app/src/aio/res/values/application.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="app_id">AIO</string>
+  <string name="app_name">中医健康辨识仪(开发版)</string>
+  <string name="app_screen">browser/kiosk?url=https://wx.hzliuzhi.com:4433/aio/?sn=45dde49f100eb0cb</string>
+  <!-- <string name="app_screen">browser/kiosk?url=http://192.168.1.14:4173/aio/?sn=45dde49f100eb0cb</string> -->
+  <!-- <string name="app_screen">browser/kiosk?url=http://192.168.31.184:4173/aio/?sn=45dde49f100eb0cb</string> -->
+</resources>

+ 12 - 0
app/src/aio/res/values/browser_proxy.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="browser_http_proxy_enabled">true</bool>
+  <string-array name="browser_proxy_pool">
+    <item>https://hybrid.reborn-tech.com -> https://wx.hzliuzhi.com/mz/hybrid</item>
+    <item>https://api.reborn-tech.com -> https://wx.hzliuzhi.com/mz/api</item>
+    <item>https://taiyi.oss-accelerate.aliyuncs.com -> https://wx.hzliuzhi.com/mz/oss</item>
+    <item>https://taiyi.oss-cn-beijing.aliyuncs.com -> https://wx.hzliuzhi.com/mz/oss</item>
+    <item>https://oss-accelerate.aliyuncs.com/taiyi -> https://wx.hzliuzhi.com/mz/oss</item>
+    <item>https://oss-cn-beijing.aliyuncs.com/taiyi -> https://wx.hzliuzhi.com/mz/oss</item>
+  </string-array>
+</resources>

+ 5 - 0
app/src/aio/res/values/pulse.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <bool name="taiyi_skip_report">false</bool>
+  <integer name="taiyi_delay_disconnect">300</integer>
+</resources>

+ 6 - 3
app/src/main/AndroidManifest.xml

@@ -10,19 +10,22 @@
       android:label="@string/app_name"
       android:roundIcon="@mipmap/ic_launcher_round"
       android:supportsRtl="true"
-      android:theme="@style/Theme.SixappletContainer"
+      android:theme="@style/Theme.Six"
       tools:targetApi="31">
     <activity
         android:name=".MainActivity"
         android:exported="true"
-        android:label="@string/app_name"
-        android:theme="@style/Theme.SixappletContainer">
+        android:theme="@style/Theme.Six">
       <intent-filter>
         <action android:name="android.intent.action.MAIN" />
 
         <category android:name="android.intent.category.LAUNCHER" />
       </intent-filter>
     </activity>
+
+    <meta-data
+        android:name="build_type_tag"
+        android:value="@string/app_id" />
   </application>
 
 </manifest>

+ 110 - 0
app/src/main/java/com/hzliuzhi/applet/container/AndroidActivity.kt

@@ -0,0 +1,110 @@
+package com.hzliuzhi.applet.container
+
+import android.graphics.Rect
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewTreeObserver
+import android.widget.FrameLayout
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+
+open class AndroidActivity : ComponentActivity() {
+  /**
+   * 软键盘适配工具
+   */
+  private var workaround: AndroidBug5497Workaround? = null
+
+  override fun onCreate(savedInstanceState: Bundle?) {
+    super.onCreate(savedInstanceState)
+    setContent() // 设置内容视图,可被子类重写
+    // 初始化软键盘适配工具
+    val content = findViewById<ViewGroup?>(android.R.id.content)
+    val child = content?.getChildAt(0)
+    workaround = if (child != null) AndroidBug5497Workaround(child) else null
+  }
+
+  /**
+   * 生命周期回调,注册软键盘适配监听。
+   */
+  override fun onResume() {
+    super.onResume()
+    workaround?.register()
+  }
+
+  /**
+   * 生命周期回调,移除软键盘适配监听。
+   */
+  override fun onPause() {
+    super.onPause()
+    workaround?.unregister()
+  }
+
+  /**
+   * 设置内容视图,默认启用EdgeToEdge,可被子类重写。
+   */
+  open fun setContent() {
+    enableEdgeToEdge()
+  }
+}
+
+/**
+ * 软键盘适配工具类,处理软键盘弹出时的布局自适应。
+ * 通过监听全局布局变化,动态调整根视图高度,防止内容被软键盘遮挡。
+ * 参考:https://stackoverflow.com/questions/7417123/android-how-to-adjust-layout-in-full-screen-mode-when-softkeyboard-is-visible
+ * @property rootView 根视图
+ */
+private class AndroidBug5497Workaround(private val rootView: View) {
+  /** 根视图的布局参数 */
+  private val rootViewLayout: FrameLayout.LayoutParams = rootView.layoutParams as FrameLayout.LayoutParams
+
+  /** 用于监听布局变化的 ViewTreeObserver */
+  private var viewTreeObserver: ViewTreeObserver = rootView.viewTreeObserver
+
+  /** 用于记录窗口内容区域的 Rect */
+  private val contentAreaOfWindowBounds = Rect()
+
+  /** 上一次可用高度 */
+  private var usableHeightPrevious = 0
+
+  /**
+   * 全局布局监听器,触发内容区域高度调整。
+   */
+  private val listener = ViewTreeObserver.OnGlobalLayoutListener { possiblyResizeChildOfContent() }
+
+  /**
+   * 注册全局布局监听器。
+   */
+  fun register() {
+    if (!viewTreeObserver.isAlive) viewTreeObserver = rootView.viewTreeObserver
+    viewTreeObserver.addOnGlobalLayoutListener(listener)
+  }
+
+  /**
+   * 移除全局布局监听器,避免内存泄漏。
+   */
+  fun unregister() {
+    if (!viewTreeObserver.isAlive) viewTreeObserver = rootView.viewTreeObserver
+    viewTreeObserver.removeOnGlobalLayoutListener(listener)
+  }
+
+  /**
+   * 判断并调整内容区域高度,适配软键盘弹出等场景。
+   * 若可用高度发生变化,则重新设置根视图高度并请求布局。
+   */
+  private fun possiblyResizeChildOfContent() {
+    (rootView.parent as? ViewGroup)?.getWindowVisibleDisplayFrame(contentAreaOfWindowBounds)
+    val usableHeightNow = contentAreaOfWindowBounds.height() + contentAreaOfWindowBounds.top
+    if (usableHeightNow != usableHeightPrevious) {
+      rootViewLayout.height = usableHeightNow
+      rootView.layout(
+        contentAreaOfWindowBounds.left,
+        contentAreaOfWindowBounds.top,
+        contentAreaOfWindowBounds.right,
+        contentAreaOfWindowBounds.bottom
+      )
+      rootView.requestLayout()
+      usableHeightPrevious = usableHeightNow
+    }
+  }
+}

+ 60 - 33
app/src/main/java/com/hzliuzhi/applet/container/MainActivity.kt

@@ -1,47 +1,74 @@
 package com.hzliuzhi.applet.container
 
-import android.os.Bundle
-import androidx.activity.ComponentActivity
+import android.annotation.SuppressLint
+import android.view.KeyEvent
+import android.widget.Toast
+import androidx.activity.compose.LocalActivity
 import androidx.activity.compose.setContent
-import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import com.hzliuzhi.applet.container.ui.theme.SixappletContainerTheme
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.rememberNavController
+import com.hzliuzhi.applet.browser.webview.WebViewBridge
+import com.hzliuzhi.applet.container.navigation.Host
+import com.hzliuzhi.applet.core.shared.Payload
+import com.hzliuzhi.applet.core.shared.SharedFlowHub
+import com.hzliuzhi.applet.core.theme.SixTheme
+import com.hzliuzhi.applet.scanner.Scanner
 
-class MainActivity : ComponentActivity() {
-  override fun onCreate(savedInstanceState: Bundle?) {
-    super.onCreate(savedInstanceState)
-    enableEdgeToEdge()
+class MainActivity : AndroidActivity() {
+  private var navController: NavHostController? = null
+
+  @SuppressLint("RestrictedApi")
+  override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+    val context = applicationContext
+    return Scanner.getInstance(context).dispatchKeyEvent(event).takeIf { it } ?: super.dispatchKeyEvent(event)
+  }
+
+  override fun setContent() {
+    super.setContent()
     setContent {
-      SixappletContainerTheme {
-        Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
-          Greeting(
-            name = "Android",
-            modifier = Modifier.padding(innerPadding)
-          )
+      navController = rememberNavController().also {
+        SixTheme {
+          Scaffold() { padding ->
+            Host(
+              navController = it,
+              modifier = Modifier.padding(padding)
+            )
+          }
         }
       }
-    }
-  }
-}
 
-@Composable
-fun Greeting(name: String, modifier: Modifier = Modifier) {
-  Text(
-    text = "Hello $name!",
-    modifier = modifier
-  )
-}
+      val context = LocalContext.current
+      val owner = LocalLifecycleOwner.current
+      Scanner.getInstance(context).observe(owner) { result ->
+        if (result == null) {
+          Payload.error<Unit>(message = "扫码失败: 结果为空").toJson()
+        } else {
+          Toast.makeText(context, result.message, Toast.LENGTH_SHORT).show()
+          when {
+            result.code.startsWith("six:") -> null
+            else -> Payload.data(data = result).toJson()
+          }
+        }?.also {
+          val event = WebViewBridge.Message(type = "scan", payload = it).toEvent()
+          SharedFlowHub.emit(event)
+        }
+      }
+      Scanner.getInstance(context).eventHandle(rememberCoroutineScope())
 
-@Preview(showBackground = true)
-@Composable
-fun GreetingPreview() {
-  SixappletContainerTheme {
-    Greeting("Android")
+      val tag = context.resources.getString(R.string.app_id)
+      when {
+        tag.startsWith("AIO") -> run {
+          LocalActivity.current?.also {
+            com.hzliuzhi.applet.device.pulse.PulseEventHandle(it, rememberCoroutineScope())
+          }
+        }
+      }
+    }
   }
 }

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

@@ -0,0 +1,38 @@
+package com.hzliuzhi.applet.container.navigation
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+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
+fun Host(
+  modifier: Modifier = Modifier,
+  navController: NavHostController = rememberNavController(),
+) {
+  NavHost(
+    startDestination = rememberRoute(),
+    navController = navController,
+    modifier = modifier.fillMaxSize(),
+  ) {
+    app(navController = navController)
+    browser(navController = navController)
+  }
+}
+
+@Composable
+fun rememberRoute(): Any {
+  val context = LocalContext.current.applicationContext
+  return remember {
+    runCatching {
+      context.resources.getString(R.string.app_screen).toRoute()
+    }.getOrNull() ?: AppRoute.Home
+  }
+}

+ 22 - 0
app/src/main/java/com/hzliuzhi/applet/container/navigation/Route.kt

@@ -0,0 +1,22 @@
+package com.hzliuzhi.applet.container.navigation
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import com.hzliuzhi.applet.container.ui.HomeScreen
+import kotlinx.serialization.Serializable
+
+sealed class AppRoute {
+  @Serializable
+  data object Home : AppRoute()
+}
+
+fun NavGraphBuilder.app(navController: NavController) {
+  composable<AppRoute.Home> { _ ->
+    HomeScreen(
+      modifier = Modifier.fillMaxSize()
+    )
+  }
+}

+ 18 - 0
app/src/main/java/com/hzliuzhi/applet/container/navigation/RouteExtra.kt

@@ -0,0 +1,18 @@
+package com.hzliuzhi.applet.container.navigation
+
+import com.hzliuzhi.applet.browser.navigation.BrowserRoute
+
+data class DeepLink(
+  val scheme: String = "six-applet://",
+)
+
+internal fun String?.toRoute(): Any? {
+  val value = this?.replace(DeepLink().scheme, "") ?: ""
+  return when {
+    value.startsWith("home") -> AppRoute.Home
+    value.startsWith("browser/update") -> BrowserRoute.Update
+    value.startsWith("browser/web?url=") -> BrowserRoute.Web().copy(url = value.substringAfter("browser/web?url="))
+    value.startsWith("browser/kiosk?url=") -> BrowserRoute.Kiosk().copy(url = value.substringAfter("browser/kiosk?url="))
+    else -> null
+  }
+}

+ 26 - 0
app/src/main/java/com/hzliuzhi/applet/container/ui/HomeScreen.kt

@@ -0,0 +1,26 @@
+package com.hzliuzhi.applet.container.ui
+
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import com.hzliuzhi.applet.core.theme.SixTheme
+
+
+@Composable
+fun HomeScreen(
+  modifier: Modifier = Modifier,
+) {
+  Text(
+    text = "Hello Home!",
+    modifier = modifier
+  )
+}
+
+@Preview(showBackground = true)
+@Composable
+fun GreetingPreview() {
+  SixTheme {
+    HomeScreen()
+  }
+}

+ 6 - 0
app/src/main/res/values/application.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="app_id">Applet</string>
+  <string name="app_name">小程序容器</string>
+  <string name="app_screen" />
+</resources>

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

@@ -1,3 +0,0 @@
-<resources>
-  <string name="app_name">Six-applet.Container</string>
-</resources>

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

@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<resources>
-
-  <style name="Theme.SixappletContainer" parent="android:Theme.Material.Light.NoActionBar" />
-</resources>

+ 6 - 0
app/src/pda-test/res/values/application.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="app_id">PDA</string>
+  <string name="app_name">中药处方煎配朔源管理(测试版)</string>
+  <string name="app_screen">browser/kiosk?url=https://wx.hzliuzhi.com:4433/pharmacy/pda/?debug=eruda</string>
+</resources>

+ 10 - 0
app/src/pda-test/res/values/browser_update.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="browser_min_version">111</string>
+  <bool name="browser_update_force">true</bool>
+  <string-array name="browser_update_package">
+    <item>com.google.android.webview</item>
+    <item>com.android.webview</item>
+    <item>com.android.chrome</item>
+  </string-array>
+</resources>

+ 6 - 0
app/src/pda/res/values/application.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="app_id">PDA</string>
+  <string name="app_name">中药处方煎配朔源管理(开发版)</string>
+  <string name="app_screen">browser/kiosk?url=http://192.168.1.14:4173/pharmacy/pda/</string>
+</resources>

+ 10 - 0
app/src/pda/res/values/browser_update.xml

@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="browser_min_version">111</string>
+  <bool name="browser_update_force">true</bool>
+  <string-array name="browser_update_package">
+    <item>com.google.android.webview</item>
+    <item>com.android.webview</item>
+    <item>com.android.chrome</item>
+  </string-array>
+</resources>

+ 1 - 0
build.gradle.kts

@@ -3,4 +3,5 @@ plugins {
   alias(libs.plugins.android.application) apply false
   alias(libs.plugins.kotlin.android) apply false
   alias(libs.plugins.kotlin.compose) apply false
+  alias(libs.plugins.android.library) apply false
 }

+ 1 - 0
core/.gitignore

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

+ 49 - 0
core/build.gradle.kts

@@ -0,0 +1,49 @@
+plugins {
+  alias(libs.plugins.android.library)
+  alias(libs.plugins.kotlin.android)
+  alias(libs.plugins.kotlin.compose)
+}
+
+android {
+  namespace = "com.hzliuzhi.applet.core"
+  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(platform(libs.androidx.compose.bom))
+  implementation(libs.androidx.ui)
+  implementation(libs.androidx.ui.graphics)
+  implementation(libs.androidx.material3)
+  testImplementation(libs.junit)
+  androidTestImplementation(libs.androidx.junit)
+  androidTestImplementation(libs.androidx.espresso.core)
+
+  implementation(libs.gson)
+}

+ 0 - 0
core/consumer-rules.pro


+ 21 - 0
core/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
core/src/androidTest/java/com/hzliuzhi/applet/core/ExampleInstrumentedTest.kt

@@ -0,0 +1,24 @@
+package com.hzliuzhi.applet.core
+
+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.core.test", appContext.packageName)
+  }
+}

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

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+</manifest>

+ 7 - 0
core/src/main/java/com/hzliuzhi/applet/core/shared/Event.kt

@@ -0,0 +1,7 @@
+package com.hzliuzhi.applet.core.shared
+
+data class Event<P, C>(
+  val type: String,
+  val payload: P?,
+  val callback: ((C) -> Unit)? = null,
+)

+ 21 - 0
core/src/main/java/com/hzliuzhi/applet/core/shared/Payload.kt

@@ -0,0 +1,21 @@
+package com.hzliuzhi.applet.core.shared
+
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+
+data class Payload<D>(val data: D? = null, val code: Int, val message: String?) {
+
+  companion object {
+    fun <D> error(code: Int = -1, message: String?) = Payload<D>(code = code, message = message)
+
+    fun <D> data(data: D, message: String? = null) = Payload(code = 0, data = data, message = message)
+  }
+
+  fun toJson(): JsonElement? {
+    return Gson().toJsonTree(this, Payload::class.java)
+  }
+
+  fun <T> copyWith(data: T?, code: Int = this.code, message: String? = this.message): Payload<T> {
+    return Payload(code = code, message = message, data = data)
+  }
+}

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

@@ -0,0 +1,35 @@
+package com.hzliuzhi.applet.core.shared
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+
+object SharedFlowHub {
+  const val WEBVIEW_BRIDGE_EVENT = "webview:bridge"
+
+  private val _events = MutableSharedFlow<Event<*, *>>(extraBufferCapacity = 16)
+  val events = _events.asSharedFlow()
+
+  fun emit(event: Event<*, *>) = _events.tryEmit(event)
+
+  @Suppress("UNCHECKED_CAST")
+  inline fun <reified P, reified C> Event<*, *>.cast(): Event<P, C>? {
+    return try {
+      Event(
+        type = this.type,
+        payload = this.payload as? P,
+        callback = this.callback as? ((C) -> Unit)
+      )
+    } catch (e: Exception) {
+      null
+    }
+  }
+
+  @Suppress("UNCHECKED_CAST")
+  inline fun <reified C> Event<*, *>.callbackAs(): ((C) -> Unit)? {
+    return callback as? ((C) -> Unit)
+  }
+
+  inline fun <reified P> Event<*, *>.payloadAs(): P? {
+    return payload as? P
+  }
+}

+ 1 - 1
app/src/main/java/com/hzliuzhi/applet/container/ui/theme/Color.kt → core/src/main/java/com/hzliuzhi/applet/core/theme/Color.kt

@@ -1,4 +1,4 @@
-package com.hzliuzhi.applet.container.ui.theme
+package com.hzliuzhi.applet.core.theme
 
 import androidx.compose.ui.graphics.Color
 

+ 2 - 3
app/src/main/java/com/hzliuzhi/applet/container/ui/theme/Theme.kt → core/src/main/java/com/hzliuzhi/applet/core/theme/Theme.kt

@@ -1,6 +1,5 @@
-package com.hzliuzhi.applet.container.ui.theme
+package com.hzliuzhi.applet.core.theme
 
-import android.app.Activity
 import android.os.Build
 import androidx.compose.foundation.isSystemInDarkTheme
 import androidx.compose.material3.MaterialTheme
@@ -34,7 +33,7 @@ private val LightColorScheme = lightColorScheme(
 )
 
 @Composable
-fun SixappletContainerTheme(
+fun SixTheme(
   darkTheme: Boolean = isSystemInDarkTheme(),
   // Dynamic color is available on Android 12+
   dynamicColor: Boolean = true,

+ 1 - 1
app/src/main/java/com/hzliuzhi/applet/container/ui/theme/Type.kt → core/src/main/java/com/hzliuzhi/applet/core/theme/Type.kt

@@ -1,4 +1,4 @@
-package com.hzliuzhi.applet.container.ui.theme
+package com.hzliuzhi.applet.core.theme
 
 import androidx.compose.material3.Typography
 import androidx.compose.ui.text.TextStyle

+ 12 - 0
core/src/main/java/com/hzliuzhi/applet/core/util/Resources.kt

@@ -0,0 +1,12 @@
+package com.hzliuzhi.applet.core.util
+
+import android.content.res.Resources
+import androidx.annotation.ArrayRes
+
+fun Resources.proxy(@ArrayRes id: Int) = getStringArray(id).mapNotNull { item ->
+  item
+    .split("->")
+    .map { it -> it.trim() }
+    .takeIf { it.size == 2 && it[0].isNotEmpty() && it[1].isNotEmpty() }
+    ?.let { it[0] to it[1] }
+}.toMap()

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

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


+ 4 - 0
core/src/main/res/values/themes.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <style name="Theme.Six" parent="android:Theme.Material.Light.NoActionBar" />
+</resources>

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

@@ -0,0 +1,17 @@
+package com.hzliuzhi.applet.core
+
+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
gradle.properties

@@ -15,6 +15,7 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
 # Android operating system, and which are packaged with your app's APK
 # https://developer.android.com/topic/libraries/support-library/androidx-rn
 android.useAndroidX=true
+android.enableJetifier=true
 # Kotlin code style for this project: "official" or "obsolete":
 kotlin.code.style=official
 # Enables namespacing of each library's R class so that its R class includes only the

+ 22 - 0
gradle/libs.versions.toml

@@ -1,6 +1,7 @@
 [versions]
 agp = "8.9.3"
 kotlin = "2.1.21"
+serializationJson = "1.7.3"
 coreKtx = "1.16.0"
 junit = "4.13.2"
 junitVersion = "1.2.1"
@@ -8,9 +9,17 @@ espressoCore = "3.6.1"
 lifecycleRuntimeKtx = "2.9.1"
 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" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
 junit = { group = "junit", name = "junit", version.ref = "junit" }
 androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
 androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -24,9 +33,22 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
 androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
 androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
 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" }
+
+# 浏览器内核更新
+webviewup-core = { module = "com.norman.webviewup:core", version = "0.1.1-alpha.01" }
+webviewup-version = { module = "io.github.g00fy2:versioncompare", version = "1.5.0" }
 
 [plugins]
 android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
 kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
 kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
 

+ 1 - 0
library/browser/.gitignore

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

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

@@ -0,0 +1,63 @@
+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(libs.webviewup.core)
+  implementation(libs.webviewup.version)
+
+  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)
+  }
+}

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

@@ -0,0 +1,31 @@
+<?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">
+
+    <provider
+        android:name="androidx.core.content.FileProvider"
+        android:authorities="${applicationId}.file.provider"
+        android:exported="false"
+        android:grantUriPermissions="true">
+      <meta-data
+          android:name="android.support.FILE_PROVIDER_PATHS"
+          android:resource="@xml/file_paths" />
+    </provider>
+  </application>
+
+  <queries>
+    <package android:name="com.google.android.webview" />
+    <package android:name="com.android.webview" />
+    <package android:name="com.android.chrome" />
+  </queries>
+</manifest>

+ 117 - 0
library/browser/src/main/assets/browser/bridge.js

@@ -0,0 +1,117 @@
+class Bridge extends EventTarget {
+  #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;
+  }
+
+  static scan({ signal, ...payload } = { signal: null }) {
+    const { promise, ...resolvers } = Promise.withResolvers();
+
+    signal?.addEventListener('abort', () => {
+      this.getInstance().#postMessage('scan:stop', null, resolvers);
+      resolvers.reject({message: '取消扫码'})
+    }, { once: true, signal });
+
+    this.getInstance().#postMessage('scan:start', payload, resolvers);
+    return promise;
+  }
+
+  dispatch(message) {
+    try {
+      const { type, callbackId, payload }  = JSON.parse(message);
+      if (callbackId) {
+        const { resolve, reject } = this.#pool.get(callbackId) ?? {};
+        this.#pool.delete(callbackId);
+        if (payload.code === 0) resolve?.(payload.data)
+        else reject?.(payload.message)
+      } else {
+        event = new CustomEvent(type, { detail: payload });
+        super.dispatchEvent(event);
+      }
+    } catch (e) {
+      console.log('log:bridge:js', `[analysis] 解析消息错误: ${e.message}`);
+      throw e;
+    }
+  }
+
+  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);
+  }
+}
+
+window['Bridge'] = Bridge;
+window['bridge'] = Bridge.getInstance();
+
+window.print = Bridge.print.bind(Bridge);
+
+// polyfill.js
+
+if (Crypto && typeof Crypto.prototype.randomUUID !== 'function') {
+  if (typeof Crypto.prototype.getRandomValues === 'function' && typeof Uint8Array === 'function') {
+    Crypto.prototype.randomUUID = function () {
+      return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, function (c) {
+        const num = Number(c);
+        return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16);
+      });
+    };
+  } else {
+    Crypto.prototype.randomUUID = function () {
+      let timestamp = new Date().getTime();
+      let per = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0;
+      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
+        let random = Math.random() * 16;
+        if (timestamp > 0) {
+          random = (timestamp + random) % 16 | 0;
+          timestamp = Math.floor(timestamp / 16);
+        } else {
+          random = (per + random) % 16 | 0;
+          per = Math.floor(per / 16);
+        }
+        return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16);
+      });
+    };
+  }
+}
+
+if (typeof Promise.withResolvers !== 'function') {
+  Promise.withResolvers = function () {
+    let resolve;
+    let reject;
+
+    const promise = new Promise((res, rej) => {
+      resolve = res;
+      reject = rej;
+    });
+
+    return { promise, resolve, reject };
+  };
+}

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

@@ -0,0 +1,38 @@
+package com.hzliuzhi.applet.browser.navigation
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+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.UpdateScreen
+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()
+  @Serializable
+  data object Update : 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"))
+  }
+  composable<BrowserRoute.Update> {
+    UpdateScreen(modifier = Modifier.fillMaxSize())
+  }
+}

+ 14 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/print/Print.kt

@@ -0,0 +1,14 @@
+package com.hzliuzhi.applet.browser.print
+
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+
+data class Print(
+  val url: String?,
+) {
+  companion object {
+    fun formJson(element: JsonElement): Print {
+      return Gson().fromJson(element, Print::class.java)
+    }
+  }
+}

+ 151 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/print/PrintEventHandler.kt

@@ -0,0 +1,151 @@
+package com.hzliuzhi.applet.browser.print
+
+import android.content.Context
+import android.os.Bundle
+import android.os.CancellationSignal
+import android.os.ParcelFileDescriptor
+import android.print.PageRange
+import android.print.PrintAttributes
+import android.print.PrintDocumentAdapter
+import android.print.PrintDocumentInfo
+import android.print.PrintManager
+import android.webkit.WebView
+import com.google.gson.JsonElement
+import com.hzliuzhi.applet.core.shared.Payload
+import com.hzliuzhi.applet.core.shared.SharedFlowHub
+import com.hzliuzhi.applet.core.shared.SharedFlowHub.callbackAs
+import com.hzliuzhi.applet.core.shared.SharedFlowHub.cast
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okio.buffer
+import okio.sink
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+
+class PrintEventHandle(webView: WebView, scope: CoroutineScope) {
+  private val printManager: PrintManager = webView.context.getSystemService(Context.PRINT_SERVICE) as PrintManager
+
+  init {
+    android.util.Log.d("log:bridge", "PrintEventHandle 被注入")
+    SharedFlowHub.events
+      .filter { it.type.contains("print") }
+      .onEach { _event ->
+        when (_event.type) {
+
+          "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:print" -> {
+            _event.cast<JsonElement, JsonElement>()?.also { event ->
+              val print = event.payload?.let { it -> Print.formJson(it) }
+              val callback = event.callback ?: {}
+              handlePrint(webView, print) { payload ->
+                payload.toJson()?.also { callback(it) }
+              }
+            }
+          }
+
+          else -> _event.callbackAs<Payload<Unit>>()?.invoke(Payload.error(message = "[print] 未实现 ${_event.type}"))
+        }
+      }
+      .launchIn(scope)
+  }
+
+  private fun handlePrint(webView: WebView, print: Print?, callback: ((Payload<Boolean?>) -> Unit)) {
+    val attributes = PrintAttributes.Builder().build()
+
+    print?.url.takeUnless { it.isNullOrEmpty() }?.also {
+      val context = webView.context.applicationContext
+      PdfPrintAdapter.download(
+        context = context, print = print!!,
+        onFinished = { file ->
+          val jobName = "${context.getString(android.R.string.untitled)}_${file.name}"
+          val adapter = PdfPrintAdapter(file, { callback(Payload.data(true));true }, { callback(Payload.error(message = it)); true })
+          printManager.print(jobName, adapter, attributes)
+        },
+        onError = { callback(Payload.error(message = it)) }
+      )
+    } ?: webView.post {
+      val jobName = "${webView.context.getString(android.R.string.untitled)}_page"
+      val adapter = webView.createPrintDocumentAdapter("WebViewDocument")
+      printManager.print(jobName, adapter, attributes)
+      callback(Payload.data(true))
+    }
+  }
+}
+
+private class PdfPrintAdapter(
+  private val file: File,
+  private val onFinished: () -> Boolean = { true },
+  private val onError: (String) -> Boolean = { true },
+) : PrintDocumentAdapter() {
+  override fun onLayout(
+    oldAttributes: PrintAttributes?,
+    newAttributes: PrintAttributes?,
+    cancellationSignal: CancellationSignal?,
+    callback: LayoutResultCallback?,
+    extras: Bundle?,
+  ) {
+    cancellationSignal?.takeIf { it.isCanceled }?.also { callback?.onLayoutCancelled(); return }
+
+    PrintDocumentInfo.Builder(file.name).apply {
+      setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT)
+      setPageCount(PrintDocumentInfo.PAGE_COUNT_UNKNOWN)
+    }.build().also { callback?.onLayoutFinished(it, true) }
+  }
+
+  override fun onWrite(
+    pages: Array<out PageRange>?,
+    destination: ParcelFileDescriptor,
+    cancellationSignal: CancellationSignal?,
+    callback: WriteResultCallback?,
+  ) {
+    runCatching {
+      FileInputStream(file).use { input ->
+        FileOutputStream(destination.fileDescriptor).use { output ->
+          input.copyTo(output)
+        }
+      }
+    }.onSuccess {
+      callback?.onWriteFinished(arrayOf(PageRange.ALL_PAGES))
+    }.onFailure {
+      callback?.onWriteFailed(it.message)
+      onError("打印错误: ${it.message ?: "失败"}").takeIf { remove -> remove }?.also { file.delete() }
+    }
+  }
+
+  override fun onFinish() {
+    super.onFinish()
+    onFinished().takeIf { remove -> remove }?.also { file.delete() }
+  }
+
+  companion object {
+    fun download(context: Context, print: Print, onFinished: (File) -> Unit, onError: (String) -> Unit) {
+      val request = Request.Builder().url(print.url!!).build()
+      OkHttpClient().newCall(request).enqueue(object : Callback {
+        override fun onFailure(call: Call, e: IOException) {
+          onError("下载失败: IO 错误")
+        }
+
+        override fun onResponse(call: Call, response: Response) {
+          if (!response.isSuccessful) onError("下载失败: ${response.message}").also { return }
+          runCatching {
+            File(context.cacheDir, "temp_${System.currentTimeMillis()}.pdf").apply {
+              sink().buffer().use { sikp ->
+                response.body?.source()?.let { sikp.writeAll(it) }
+              }
+            }
+          }.onSuccess { onFinished(it) }.onFailure { onError("下载失败: ${it.message}") }
+        }
+      })
+    }
+  }
+}
+
+

+ 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).method(request.method, null).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()
+  }
+}

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

@@ -0,0 +1,55 @@
+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.NetworkMask
+import com.hzliuzhi.applet.browser.ui.components.ProgressBar
+import com.hzliuzhi.applet.browser.update.WebViewUpdate
+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,
+    )
+
+    NetworkMask(
+      waitSecond = 5,
+      modifier = Modifier.zIndex(2f),
+      onRefresh = { controller.navigator.reload() }
+    ) {
+      WebViewUpdate {
+        WebView(
+          layoutParams = layoutParams,
+          controller = controller,
+          onCreated = { controller.connected(it, content) },
+          onDispose = { controller.unconnected(it) }
+        )
+
+        ProgressBar(state.load, modifier = Modifier.zIndex(1f))
+      }
+    }
+  }
+}

+ 275 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/UpdateScreen.kt

@@ -0,0 +1,275 @@
+package com.hzliuzhi.applet.browser.ui
+
+import android.annotation.SuppressLint
+import android.util.Log
+import android.widget.Toast
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.RadioButtonDefaults
+import androidx.compose.material3.Text
+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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.hzliuzhi.applet.browser.update.UpdatePackage
+import com.hzliuzhi.applet.browser.update.UpdateUtil
+import com.hzliuzhi.applet.browser.update.WebViewUpdate
+import com.hzliuzhi.applet.browser.update.rememberWebViewUpdateController
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+@Composable
+fun UpdateScreen(
+  modifier: Modifier = Modifier,
+) {
+  val context = LocalContext.current
+  val controller = rememberWebViewUpdateController()
+
+  val system by remember { mutableStateOf(controller.system) }
+  var current by remember { mutableStateOf<UpdatePackage?>(null) }
+  var throwable by remember { mutableStateOf<Throwable?>(null) }
+
+  var packages by remember { mutableStateOf<List<UpdatePackage>>(emptyList()) }
+  var selected by remember { mutableStateOf<UpdatePackage?>(null) }
+
+  val updating by controller.updating.collectAsState()
+  val process by controller.process.collectAsState()
+
+  LaunchedEffect(Unit) {
+    packages = withContext(Dispatchers.IO) {
+      controller.packages.toList()
+    }
+  }
+
+
+  fun onTest() {
+    current = UpdateUtil.getSystemPackage().takeIf { it != system }?.also {
+      Toast.makeText(context, "更新成功 ${it.packageName}", Toast.LENGTH_SHORT).show()
+    }
+  }
+
+  val scope = rememberCoroutineScope()
+  fun onUpdate() {
+    packages.firstOrNull { it == selected }?.also {
+      throwable = null
+      scope.launch {
+        withContext(Dispatchers.IO) {
+          controller.update(it) { error ->
+            // 回到主线程处理回调
+            if (error == null) onTest() else throwable = error
+          }
+        }
+      }
+    } ?: Toast.makeText(context, "请选择要更新的包", Toast.LENGTH_SHORT).show()
+  }
+
+
+  Column(
+    modifier = modifier.padding(16.dp),
+    verticalArrangement = Arrangement.spacedBy(16.dp),
+  ) {
+    PackageDescription("系统 WebView", system)
+    UpdatePackages(
+      modifier = Modifier
+        .fillMaxWidth()
+        .weight(1f),
+      packages = packages,
+      selected = selected,
+      onSelected = { selected = it }
+    )
+
+    if (throwable != null) OutlinedTextField(
+      value = Log.getStackTraceString(throwable),
+      modifier = Modifier
+        .fillMaxWidth()
+        .weight(0.4f),
+      label = { Text(throwable!!.message.toString()) },
+      onValueChange = {},
+      readOnly = true,
+    )
+
+    PackageDescription("应用 WebView", current)
+
+    Row(
+      modifier = Modifier
+        .fillMaxWidth()
+        .padding(horizontal = 16.dp),
+      horizontalArrangement = Arrangement.spacedBy(16.dp),
+      verticalAlignment = Alignment.Bottom
+    ) {
+      Button(
+        modifier = Modifier
+          .weight(1f)
+          .height(48.dp),
+        enabled = true,
+        onClick = { if (!updating) onUpdate() }
+      ) {
+        Row(
+          horizontalArrangement = Arrangement.spacedBy(8.dp),
+          verticalAlignment = Alignment.CenterVertically
+        ) {
+          if (updating) {
+            CircularProgressIndicator(
+              modifier = Modifier.size(24.dp),
+              color = MaterialTheme.colorScheme.onPrimary,
+              strokeWidth = 2.dp
+            )
+          }
+          Text(
+            text = if (updating) process.format() else "加载",
+            style = MaterialTheme.typography.labelLarge.copy(
+              fontSize = 16.sp
+            )
+          )
+        }
+      }
+      OutlinedButton(
+        modifier = Modifier
+          .weight(1f)
+          .height(48.dp),
+        enabled = !updating,
+        onClick = { onTest() }
+      ) {
+        Text(
+          text = "测试",
+          style = MaterialTheme.typography.labelLarge.copy(
+            fontSize = 16.sp
+          )
+        )
+      }
+    }
+  }
+}
+
+@SuppressLint("DefaultLocale")
+private fun Float.format(): String {
+  return String.format("%05.2f%%", this * 100)
+}
+
+@Composable
+fun PackageDescription(label: String, info: UpdatePackage?) {
+  if (info != null) OutlinedTextField(
+    value = info.toString(),
+    modifier = Modifier.fillMaxWidth(),
+    label = { Text(label) },
+    onValueChange = {},
+    readOnly = true,
+    enabled = info.packageName.isNotEmpty() && info.packageName != "unknown",
+  )
+}
+
+@Composable
+fun UpdatePackages(
+  packages: List<UpdatePackage>,
+  selected: UpdatePackage?,
+  modifier: Modifier = Modifier,
+  onSelected: (UpdatePackage) -> Unit = {},
+) {
+  val grouped = packages.groupBy { it.type.label }
+  val listState = rememberLazyListState()
+
+  // 默认滚动到选中项
+  LaunchedEffect(selected) {
+    val index = packages.indexOfFirst { it == selected }
+    if (index != -1) {
+      listState.animateScrollToItem(index)
+    }
+  }
+
+  Box(modifier = modifier) {
+    LazyColumn(state = listState) {
+      grouped.forEach { (group, packages) ->
+        val isGroupSelected = group == selected?.type?.label
+        stickyHeader {
+          Box(
+            modifier = Modifier
+              .fillMaxWidth()
+              .background(
+                if (isGroupSelected) MaterialTheme.colorScheme.primaryContainer
+                else MaterialTheme.colorScheme.surface
+              )
+              .padding(vertical = 8.dp, horizontal = 16.dp)
+          ) {
+            Text(
+              text = group,
+              style = MaterialTheme.typography.titleMedium,
+              color = if (isGroupSelected) MaterialTheme.colorScheme.onPrimaryContainer
+              else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+              fontWeight = if (isGroupSelected) FontWeight.Bold else FontWeight.Normal
+            )
+          }
+        }
+        items(packages.size) { index ->
+          val item = packages[index]
+          val isSelected = item == selected
+
+          Row(
+            modifier = modifier
+              .background(
+                if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f)
+                else Color.Transparent
+              )
+              .padding(horizontal = 16.dp, vertical = 8.dp)
+              .selectable(isSelected, onClick = { onSelected(item) }),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+            verticalAlignment = Alignment.CenterVertically,
+          ) {
+            RadioButton(
+              selected = isSelected,
+              onClick = { onSelected(item) },
+              colors = RadioButtonDefaults.colors(
+                selectedColor = MaterialTheme.colorScheme.primary,
+                unselectedColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
+              )
+            )
+            Column(modifier = Modifier.weight(1f)) {
+              Text(
+                text = item.packageName,
+                style = MaterialTheme.typography.bodyLarge,
+                color = if (isSelected) MaterialTheme.colorScheme.primary
+                else MaterialTheme.colorScheme.onSurface
+              )
+              Spacer(modifier = Modifier.height(2.dp))
+              item.versionName?.let {
+                Text(
+                  text = it,
+                  style = MaterialTheme.typography.bodyMedium,
+                  color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
+                )
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}

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

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

@@ -0,0 +1,149 @@
+package com.hzliuzhi.applet.browser.ui.components
+
+import android.content.Context
+import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.provider.Settings
+import android.widget.Toast
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import kotlinx.coroutines.delay
+
+@Composable
+fun NetworkMask(
+  modifier: Modifier = Modifier,
+  onOpenSettings: (() -> Unit)? = null,
+  onRefresh: (() -> Unit)? = null,
+  waitSecond: Int = 0,
+  content: (@Composable () -> Unit)? = null,
+) {
+  val context = LocalContext.current
+  val networkAvailable by rememberNetworkAvailable(context)
+
+  var lastNetworkAvailable by remember { mutableStateOf<Boolean?>(null) }
+
+
+  var loaded by remember { mutableStateOf(false) }
+  var showDialog by remember { mutableStateOf(false) }
+  var showLoading by remember { mutableStateOf(false) }
+
+  // 默认跳转到系统设置的方法
+  val defaultOpenSettings: () -> Unit = {
+    Intent(Settings.ACTION_WIRELESS_SETTINGS).apply {
+      addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+      context.startActivity(this)
+    }
+  }
+
+  LaunchedEffect(networkAvailable, waitSecond) {
+    val last = lastNetworkAvailable
+    if (last == null && waitSecond > 0) {
+      showLoading = !networkAvailable
+      showDialog = false
+      if (showLoading) delay(waitSecond * 1000L)
+    }
+
+    lastNetworkAvailable = networkAvailable
+    showDialog = !networkAvailable
+    showLoading = false
+
+    when (last) {
+      false -> /* 网络从断开(false)变为连接(true)时 */ if (networkAvailable) onRefresh?.invoke()
+      null -> /* 首次连接时 */ loaded = true
+      else -> loaded = true
+    }
+  }
+
+  showLoading.takeIf { it }?.let {
+    Box(
+      modifier = modifier.fillMaxSize(),
+      contentAlignment = Alignment.Center
+    ) {
+      Column(horizontalAlignment = Alignment.CenterHorizontally) {
+        CircularProgressIndicator()
+        Text("正在检测网络...")
+      }
+    }
+  }
+
+  showDialog.takeIf { it }?.let {
+    AlertDialog(
+      modifier = modifier,
+      onDismissRequest = {},
+      title = { Text("无网络连接") },
+      text = { Text("请检查您的网络设置。") },
+      confirmButton = {
+        Button(onClick = onOpenSettings ?: defaultOpenSettings) {
+          Text("打开设置")
+        }
+      },
+      dismissButton = {
+        Button(onClick = {
+          if (checkNetworkConnected(context)) {
+            showDialog = false
+            onRefresh?.invoke()
+          } else {
+            Toast.makeText(context, "网络未连接", Toast.LENGTH_SHORT).show()
+          }
+        }) {
+          Text("刷新")
+        }
+      }
+    )
+  }
+
+  if (loaded) content?.invoke()
+}
+
+@Composable
+fun rememberNetworkAvailable(context: Context = LocalContext.current): MutableState<Boolean> {
+  val networkAvailable = remember { mutableStateOf(true) }
+
+  DisposableEffect(context) {
+    val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+    val callback = object : ConnectivityManager.NetworkCallback() {
+      override fun onAvailable(network: Network) {
+        networkAvailable.value = true
+      }
+
+      override fun onLost(network: Network) {
+        networkAvailable.value = checkNetworkConnected(context)
+      }
+    }
+    val request = NetworkRequest.Builder()
+      .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+      .build()
+    cm.registerNetworkCallback(request, callback)
+    networkAvailable.value = checkNetworkConnected(context)
+    onDispose {
+      cm.unregisterNetworkCallback(callback)
+    }
+  }
+  return networkAvailable
+}
+
+private fun checkNetworkConnected(context: Context): Boolean {
+  val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+  val network = cm.activeNetwork ?: return false
+  val capabilities = cm.getNetworkCapabilities(network) ?: return false
+  return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+}

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

+ 19 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateController.kt

@@ -0,0 +1,19 @@
+package com.hzliuzhi.applet.browser.update
+
+import android.content.Context
+
+class UpdateController private constructor(context: Context) : UpdateSource(context) {
+  private val minVersion by lazy { UpdateUtil.getMinVersion(context.resources) }
+  val system by lazy { UpdateUtil.getSystemPackage() }
+  val packages by lazy { getUpdatePackages(context) }
+
+  companion object {
+    fun init(context: Context) = UpdateController(context.applicationContext)
+  }
+
+  private fun getUpdatePackages(context: Context, packages: List<String> = UpdateUtil.getPackages(context.resources)): Sequence<UpdatePackage> {
+    val internal = UpdateUtil.getInternalUpdatePackage(context, packages = packages, minVersion = minVersion)
+    val assets = UpdateUtil.getAssetUpdatePackage(context, packages = packages, minVersion = minVersion)
+    return internal + assets
+  }
+}

+ 34 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdatePackage.kt

@@ -0,0 +1,34 @@
+package com.hzliuzhi.applet.browser.update
+
+data class UpdatePackage(
+  val type: Type,
+  val packageName: String,
+  val versionName: String? = "",
+
+  val path: String? = null,
+) {
+  override fun equals(other: Any?): Boolean {
+    return other is UpdatePackage
+        && this.packageName == other.packageName
+        && this.versionName == other.versionName
+  }
+
+  override fun hashCode(): Int {
+    val p = packageName.hashCode()
+    val v = versionName?.hashCode() ?: 0
+    return 31 * p + v;
+  }
+
+  override fun toString() = "${type.typeName}(packageName=$packageName, versionName=$versionName)"
+
+  enum class Type(val value: Int, val label: String) {
+    INTERNAL(1, "应用"),
+    ASSET(2, "资源"),
+    FILE(3, "文件"),
+    NETWORK(4, "网络"),
+    SYSTEM(0, "系统"),
+    ;
+
+    val typeName: String get() = name.lowercase().replaceFirstChar { it.uppercaseChar() }
+  }
+}

+ 66 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateSource.kt

@@ -0,0 +1,66 @@
+package com.hzliuzhi.applet.browser.update
+
+import android.content.Context
+import com.hzliuzhi.applet.browser.update.UpdatePackage.Type.ASSET
+import com.hzliuzhi.applet.browser.update.UpdatePackage.Type.INTERNAL
+import com.hzliuzhi.applet.browser.update.UpdatePackage.Type.SYSTEM
+import com.hzliuzhi.applet.browser.update.UpdateUtil.DIR
+import com.norman.webviewup.lib.UpgradeCallback
+import com.norman.webviewup.lib.WebViewUpgrade
+import com.norman.webviewup.lib.source.UpgradeAssetSource
+import com.norman.webviewup.lib.source.UpgradePackageSource
+import com.norman.webviewup.lib.source.UpgradeSource
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import java.io.File
+
+open class UpdateSource(val context: Context) {
+  private val _updating = MutableStateFlow(false)
+  val updating: StateFlow<Boolean> = _updating
+
+
+  private val _process = MutableStateFlow(0f)
+  val process: StateFlow<Float> = _process
+
+  open fun update(info: UpdatePackage, onComplete: ((Throwable?) -> Unit)? = null) {
+    object : UpgradeCallback {
+      override fun onUpgradeProcess(process: Float) {
+        _process.value = process
+      }
+
+      override fun onUpgradeComplete() {
+        _updating.value = false
+        onComplete?.invoke(null)
+        WebViewUpgrade.removeUpgradeCallback(this)
+      }
+
+      override fun onUpgradeError(throwable: Throwable?) {
+        _updating.value = false
+        onComplete?.invoke(throwable ?: Throwable("异常"))
+        WebViewUpgrade.removeUpgradeCallback(this)
+      }
+    }.also { WebViewUpgrade.addUpgradeCallback(it) }
+
+    _process.value = 0.0f
+    _updating.value = true
+    val source = toSource(info);
+    WebViewUpgrade.upgrade(source)
+  }
+
+
+  private fun toSource(info: UpdatePackage): UpgradeSource {
+    return when (info.type) {
+      SYSTEM,
+      INTERNAL,
+        -> UpgradePackageSource(context.applicationContext, info.packageName)
+
+      ASSET -> UpgradeAssetSource(context.applicationContext, info.path!!, getInstallFile(info))
+      else -> throw IllegalArgumentException("不支持的 UpdatePackage 类型: ${info.type}")
+    }
+  }
+
+  private fun getInstallFile(info: UpdatePackage): File {
+    return File(context.applicationContext.filesDir, "$DIR/${info.packageName}/${info.versionName ?: "0"}.apk")
+  }
+}
+

+ 12 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateState.kt

@@ -0,0 +1,12 @@
+package com.hzliuzhi.applet.browser.update
+
+sealed class UpdateState {
+  data class Loading(val process: Float) : UpdateState()
+  data class Error(val error: Throwable?) : UpdateState()
+  data object Success : UpdateState()
+
+
+  companion object {
+    fun start() = Loading(0f)
+  }
+}

+ 97 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateUtil.kt

@@ -0,0 +1,97 @@
+package com.hzliuzhi.applet.browser.update
+
+import android.content.Context
+import android.content.res.Resources
+import android.webkit.WebView
+import androidx.annotation.ArrayRes
+import androidx.annotation.BoolRes
+import androidx.annotation.StringRes
+import com.hzliuzhi.applet.browser.R
+import io.github.g00fy2.versioncompare.Version
+
+object UpdateUtil {
+  const val DIR = "browser"
+  private val REGEX = Regex("""^(?:.*/)?([^/]+)/([0-9.]+)(?:-\d+)?\.(?:apk|zip|aio)$""")
+  private val defaultPackageName = listOf(
+    "com.google.android.webview",
+    "com.android.webview",
+    "com.android.chrome",
+  )
+
+  fun getMinVersion(resources: Resources) = resources.getStringOrDefault(R.string.browser_min_version, "0")
+
+  fun isForceUpdate(resources: Resources) = resources.getBooleanOrDefault(R.bool.browser_update_force, false)
+
+  fun getPackages(resources: Resources) = resources.getStringArrayOrDefault(R.array.browser_update_package).toList().takeIf { it.isNotEmpty() } ?: defaultPackageName
+
+  fun getInternalUpdatePackage(
+    context: Context,
+    packages: List<String> = getPackages(context.resources),
+    minVersion: String = getMinVersion(context.resources),
+  ) = context.packageManager.let { pm ->
+    packages.asSequence().mapNotNull { packageName ->
+      runCatching {
+        pm.getPackageInfo(packageName, 0).takeIf { it.versionName.satisfy(minVersion) }?.let { packageInfo ->
+          UpdatePackage(
+            type = UpdatePackage.Type.INTERNAL,
+            packageName = packageInfo.packageName,
+            versionName = packageInfo.versionName,
+          )
+        }
+      }.getOrNull()
+    }
+  }
+
+  fun getAssetUpdatePackage(
+    context: Context,
+    packages: List<String> = getPackages(context.resources),
+    minVersion: String = getMinVersion(context.resources),
+  ): Sequence<UpdatePackage> {
+    val paths = context.resources.getStringArrayOrDefault(R.array.browser_update_asset_path).asSequence()
+    val assets = packages.asSequence().flatMap { packageName ->
+      val dir = DIR
+      sequence {
+        val files = runCatching { context.assets.list("$dir/$packageName") }.getOrNull()
+        files?.forEach { file ->
+          yield("$dir/$packageName/$file")
+        }
+      }
+    }
+
+    return paths.plus(assets).mapNotNull { path ->
+      REGEX.matchEntire(path)
+        ?.destructured
+        ?.takeIf { it.component2().satisfy(minVersion) }
+        ?.let { (packageName, versionName) ->
+          UpdatePackage(
+            type = UpdatePackage.Type.ASSET,
+            packageName = packageName,
+            versionName = versionName,
+            path = path,
+          )
+        }
+    }
+  }
+  fun getSystemPackage() = WebView.getCurrentWebViewPackage().let { info ->
+    UpdatePackage(
+      UpdatePackage.Type.SYSTEM,
+      packageName = info?.packageName ?: "unknown",
+      versionName = info?.versionName ?: "0",
+    )
+  }
+  fun getSystemSatisfy(context: Context, minVersion: String = getMinVersion(context.resources)) = minVersion == "0" || getSystemPackage().versionName.satisfy(minVersion)
+}
+
+
+@Suppress("UNCHECKED_CAST")
+private fun <R : String?> Resources.getStringOrDefault(@StringRes id: Int, defaultValue: R): R =
+  runCatching { getString(id) as R }.getOrElse { defaultValue }
+
+@Suppress("UNCHECKED_CAST")
+private fun <R : Boolean?> Resources.getBooleanOrDefault(@BoolRes id: Int, defaultValue: R): R =
+  runCatching { getBoolean(id) as R }.getOrElse { defaultValue }
+
+private fun Resources.getStringArrayOrDefault(@ArrayRes id: Int, defaultValue: Array<String> = emptyArray()) =
+  runCatching { getStringArray(id).toSet().toTypedArray() }.getOrDefault(defaultValue)
+
+private fun String?.satisfy(min: String) = Version(this ?: "0").isAtLeast(min)

+ 189 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/update/WebViewUpdate.kt

@@ -0,0 +1,189 @@
+package com.hzliuzhi.applet.browser.update
+
+import android.content.Context
+import android.util.Log
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Build
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+
+@Composable
+fun WebViewUpdate(
+  controller: UpdateController = rememberWebViewUpdateController(),
+  content: (@Composable () -> Unit)? = null,
+) {
+  val context = LocalContext.current
+  val force by remember { mutableStateOf(UpdateUtil.isForceUpdate(context.resources)) }
+  val state = remember {
+    val value = if (UpdateUtil.getSystemSatisfy(context)) UpdateState.Success else UpdateState.start()
+    mutableStateOf(value)
+  }
+
+  when (val value = state.value) {
+    is UpdateState.Loading -> WebViewUpdateLoading(controller) { it ->
+      state.value = it?.takeIf { force }?.let { UpdateState.Error(it) } ?: UpdateState.Success
+    }
+
+    UpdateState.Success -> content?.invoke() ?: Text("更新成功")
+
+    is UpdateState.Error -> WebViewUpdateError(value.error) {
+      state.value = UpdateState.start()
+    }
+  }
+}
+
+@Composable
+private fun WebViewUpdateLoading(controller: UpdateController, onResult: (Throwable?) -> Unit) {
+  LaunchedEffect(Unit) {
+    var lastError: Throwable? = null
+    val start = System.currentTimeMillis()
+
+    val result = controller.check { lastError = it }.also { onResult(if (it) null else lastError) }
+
+    (System.currentTimeMillis() - start).also {
+      val duration = it / 1000.0
+      Log.d("log:WU", "webview 更新 $result, 耗时 ${duration}s")
+    }
+  }
+
+  Box(
+    modifier = Modifier.fillMaxSize(),
+    contentAlignment = Alignment.Center
+  ) {
+    CircularProgressIndicator()
+  }
+}
+
+@Composable
+fun WebViewUpdateError(error: Throwable?, onRetry: () -> Unit) {
+  val scrollState = rememberScrollState()
+  Box(
+    modifier = Modifier
+      .fillMaxSize()
+      .background(Color(0xFFFDF6F6)),
+    contentAlignment = Alignment.Center
+  ) {
+    Card(
+      modifier = Modifier.padding(24.dp),
+      colors = CardDefaults.cardColors(containerColor = Color.White),
+      elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+    ) {
+      Column(
+        modifier = Modifier
+          .padding(24.dp)
+          .heightIn(min = 240.dp),
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.SpaceBetween
+      ) {
+        Icon(
+          imageVector = Icons.Filled.Build,
+          contentDescription = "错误",
+          tint = Color(0xFFD32F2F),
+          modifier = Modifier.size(48.dp)
+        )
+        Spacer(modifier = Modifier.height(12.dp))
+        Text(
+          text = error?.javaClass?.simpleName ?: "未知异常",
+          color = Color(0xFFD32F2F),
+          fontWeight = FontWeight.Bold,
+          fontSize = 20.sp
+        )
+        Spacer(modifier = Modifier.height(8.dp))
+        error?.message?.let {
+          Text(
+            text = it,
+            color = Color(0xFFB71C1C),
+            fontSize = 16.sp
+          )
+        }
+        Spacer(modifier = Modifier.height(8.dp))
+        error?.let {
+          Text(
+            text = it.stackTraceToString(),
+            modifier = Modifier
+              .height(160.dp)
+              .verticalScroll(scrollState)
+              .background(Color(0xFFF5F5F5))
+              .padding(8.dp),
+            color = Color(0xFF616161),
+            fontSize = 12.sp
+          )
+        }
+        Spacer(modifier = Modifier.height(16.dp))
+        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+          OutlinedButton(
+            onClick = onRetry,
+            contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
+            modifier = Modifier.height(28.dp)
+          ) {
+            Text("重试", fontSize = 13.sp)
+          }
+        }
+        Text(
+          text = "请截图联系管理员",
+          color = Color(0xFFABABAB),
+          fontSize = 8.sp,
+          modifier = Modifier
+            .align(Alignment.CenterHorizontally)
+            .padding(top = 2.dp)
+        )
+      }
+    }
+  }
+}
+
+private suspend fun UpdateController.check(action: (exception: Throwable) -> Unit = {}): Boolean {
+  var exception: Throwable? = null
+  return packages.firstOrNull { pkg ->
+    runCatching {
+      withContext(Dispatchers.IO) {
+        suspendCancellableCoroutine<Unit> { cont ->
+          update(pkg) { error ->
+            if (error == null) cont.resume(Unit)
+            else cont.resumeWithException(error)
+          }
+        }
+      }
+    }.onFailure { exception = it }.isSuccess
+  }?.let { true } ?: action(exception ?: NoSuchElementException("更新包为空")).let { false }
+}
+
+
+@Composable
+fun rememberWebViewUpdateController(context: Context = LocalContext.current) = remember { UpdateController.init(context.applicationContext) }

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

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

@@ -0,0 +1,54 @@
+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)
+  WebViewFileChooser(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)
+}

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

@@ -0,0 +1,100 @@
+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.browser.print.PrintEventHandle
+import com.hzliuzhi.applet.core.shared.Event
+import com.hzliuzhi.applet.core.shared.SharedFlowHub
+import com.hzliuzhi.applet.core.shared.SharedFlowHub.cast
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+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
+      }
+    }
+
+    fun toEvent() = Event<Message, String>(
+      type = "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:js",
+      payload = this
+    )
+  }
+
+
+  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) }
+
+    SharedFlowHub.events.filter { it.type == "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:js" }.onEach { event ->
+      Log.d("log:bridge", "发送消息事件: $event")
+      event.cast<Message, String>()?.also {
+        val payload = Gson().toJson(it.payload)
+        webview.evaluateJavascript("Bridge.getInstance().dispatch(${JSONObject.quote(payload)})", it.callback)
+      }
+    }.launchIn(coroutineScope)
+
+    PrintEventHandle(webview, coroutineScope)
+  }
+
+
+  private val messages = MutableSharedFlow<Message>(replay = 1)
+
+  @SuppressLint("JavascriptInterface")
+  internal suspend fun WebView.handleBridge(): Nothing = withContext(Dispatchers.Main) {
+    addJavascriptInterface(this@WebViewBridge, "AndroidBridge")
+    messages.collect { SharedFlowHub.webViewEmit(it) }
+  }
+
+  private fun SharedFlowHub.webViewEmit(message: Message) {
+    Log.d("log:bridge", "接收消息: $message")
+    Event<JsonElement, JsonElement>(
+      type = "$WEBVIEW_BRIDGE_EVENT:${message.type}",
+      payload = message.payload,
+      callback = { payload ->
+        val event = message.copy(payload = payload).toEvent()
+        emit(event)
+      }
+    ).also { emit(it) }
+  }
+
+  /* 注入到 JavaScript 中的方法 */
+  @JavascriptInterface
+  fun postMessage(string: String) {
+    Message.fromJson(string)?.also {
+      messages.tryEmit(it)
+    }
+  }
+}

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

@@ -0,0 +1,50 @@
+package com.hzliuzhi.applet.browser.webview
+
+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)
+  val fileChooser = MutableStateFlow<FileChooserRequest?>(null)
+
+  fun connected(webview: WebView, content: WebContent) {
+    this.webview = webview
+    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()
+  }
+}
+

+ 171 - 0
library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewFileChooser.kt

@@ -0,0 +1,171 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.content.Context
+import android.net.Uri
+import android.os.Environment
+import android.webkit.ValueCallback
+import android.webkit.WebChromeClient.FileChooserParams
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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 androidx.compose.ui.platform.LocalContext
+import androidx.core.content.FileProvider
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun WebViewFileChooser(controller: WebViewController) {
+  val context = LocalContext.current.applicationContext
+
+  val fileChooser by controller.fileChooser.collectAsState()
+  var showDialog by remember { mutableStateOf(false) }
+  var temporaryUri by remember { mutableStateOf<Uri?>(null) }
+
+  var file: File? = null
+
+
+  fun callback(values: Array<Uri>?) {
+    fileChooser?.callback?.onReceiveValue(values ?: emptyArray())
+    controller.fileChooser.value = null
+    file = null
+  }
+
+  fun callback(value: Uri?) = callback(if (value != null) arrayOf(value) else emptyArray())
+
+  fun callback() {
+    callback(emptyArray())
+    // 清理未用的临时文件
+    file?.also { deleteTempFileByUri(it) }
+    file = null
+    showDialog = false
+  }
+
+  val capturePictureLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.TakePicture()
+  ) { success ->
+    temporaryUri.takeIf { success }?.also { callback(it) } ?: callback()
+    temporaryUri = null
+  }
+
+  val selectPictureLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.GetContent(),
+    onResult = ::callback
+  )
+
+  val selectMultiPictureLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.GetMultipleContents()
+  ) { uris ->
+    callback(uris.takeIf { it.isNotEmpty() }?.toTypedArray())
+  }
+
+  // 单选 launcher
+  val singleLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.GetContent(),
+    onResult = ::callback
+  )
+
+  // 多选 launcher
+  val multiLauncher = rememberLauncherForActivityResult(
+    contract = ActivityResultContracts.OpenMultipleDocuments()
+  ) { uris ->
+    callback(uris.takeIf { it.isNotEmpty() }?.toTypedArray())
+  }
+
+  val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
+  fun capturePicture() {
+    file?.also { deleteTempFileByUri(it) }
+    if (cameraPermissionState.status.isGranted) {
+      temporaryUri = createImageUri(context) { file = it }.also { capturePictureLauncher.launch(it) }
+    } else {
+      cameraPermissionState.launchPermissionRequest()
+    }
+    showDialog = false
+  }
+
+  fun selectPicture() {
+    showDialog = false
+    if (fileChooser?.params?.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
+      selectMultiPictureLauncher.launch("image/*")
+    } else {
+      selectPictureLauncher.launch("image/*")
+    }
+  }
+
+  LaunchedEffect(fileChooser) {
+    fileChooser?.let { request ->
+      val mimeTypes = request.params?.acceptTypes?.filter { it.isNotBlank() }?.toTypedArray()
+      val mimeType = when {
+        mimeTypes.isNullOrEmpty() -> "*/*"
+        mimeTypes.size == 1 -> mimeTypes[0]
+        else -> "*/*"
+      }
+
+      if (mimeType.startsWith("image/")) {
+        request.params?.isCaptureEnabled?.takeIf { it }?.also {
+          capturePicture()
+        } ?: run {
+          showDialog = true
+        }
+      } else if (request.params?.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
+        multiLauncher.launch(mimeTypes?.takeIf { it.isNotEmpty() } ?: arrayOf("*/*"))
+      } else {
+        singleLauncher.launch(mimeType)
+      }
+    }
+  }
+
+  if (showDialog) {
+    AlertDialog(
+      onDismissRequest = ::callback,
+      title = { Text("选择操作") },
+      text = { Text("请选择要拍照还是从相册选择照片") },
+      confirmButton = {
+        TextButton(onClick = ::capturePicture) { Text("拍照") }
+      },
+      dismissButton = {
+        TextButton(onClick = ::selectPicture) { Text("选择照片") }
+      }
+    )
+  }
+
+  BackHandler(showDialog) {
+    callback()
+  }
+}
+
+// 创建临时图片 Uri
+private fun createImageUri(context: Context, onSave: ((File) -> Unit) = {}): Uri {
+  val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
+  val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
+  val file = File.createTempFile("JPEG_${timeStamp}_", ".jpg", storageDir).also { onSave.invoke(it) }
+  return FileProvider.getUriForFile(context, "${context.packageName}.file.provider", file)
+}
+
+private fun deleteTempFileByUri(file: File): Boolean {
+  return try {
+    if (file.exists()) file.delete() else false
+  } catch (_: Exception) {
+    false
+  }
+}
+
+data class FileChooserRequest(
+  val callback: ValueCallback<Array<Uri>>,
+  val params: FileChooserParams?,
+)

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

@@ -0,0 +1,87 @@
+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 androidx.core.net.toUri
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlin.random.Random
+
+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.timestamp(), 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 -> {}
+      }
+    }
+  }
+
+  private fun String.timestamp(): String {
+    return try {
+      val uri = toUri()
+      val timestamp = System.currentTimeMillis()
+      return uri.buildUpon().apply {
+        appendQueryParameter("_t", timestamp.toString())
+        appendQueryParameter("_r", Random.nextInt(1000).toString())
+      }.build().toString()
+    } catch (e: Exception) {
+      this
+    }
+  }
+}

+ 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()
+  val 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

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

@@ -0,0 +1,47 @@
+package com.hzliuzhi.applet.browser.webview
+
+import android.graphics.Bitmap
+import android.net.Uri
+import android.webkit.PermissionRequest
+import android.webkit.ValueCallback
+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
+  }
+
+  override fun onShowFileChooser(webView: WebView?, filePathCallback: ValueCallback<Array<Uri>>?, fileChooserParams: FileChooserParams?): Boolean {
+    return filePathCallback?.let {
+      controller.fileChooser.value = FileChooserRequest(filePathCallback, fileChooserParams)
+      true
+    } ?: false
+  }
+}

+ 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?.takeIf { 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>

+ 8 - 0
library/browser/src/main/res/values/update.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <string name="browser_min_version">0</string>
+  <bool name="browser_update_force"/>
+  <string-array name="browser_update_package"/>
+
+  <string-array name="browser_update_asset_path" />
+</resources>

+ 4 - 0
library/browser/src/main/res/xml/file_paths.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+  <external-files-path name="images" path="Pictures" />
+</paths>

+ 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
library/device/pulse/.gitignore

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

+ 58 - 0
library/device/pulse/build.gradle.kts

@@ -0,0 +1,58 @@
+plugins {
+  alias(libs.plugins.android.library)
+  alias(libs.plugins.kotlin.android)
+  alias(libs.plugins.kotlin.compose)
+}
+
+android {
+  namespace = "com.hzliuzhi.applet.device.pulse"
+  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(platform(libs.androidx.compose.bom))
+  testImplementation(libs.junit)
+  androidTestImplementation(libs.androidx.junit)
+  androidTestImplementation(libs.androidx.espresso.core)
+
+  implementation(project(":core"))
+  implementation(libs.gson)
+
+  implementation("com.taiyi.sdk.pulse:ble:1.1.0-alpha.02")
+  implementation("com.android.volley:volley:1.2.1")
+  implementation("com.opencsv:opencsv:5.6")
+  implementation("com.aliyun.dpa:oss-android-sdk:2.9.21")
+  implementation("com.jakewharton.rxbinding2:rxbinding:2.0.0")
+  implementation("io.reactivex.rxjava2:rxjava:2.1.7")
+  implementation("io.reactivex.rxjava2:rxandroid:2.0.1")
+  implementation("org.greenrobot:eventbus:3.3.1")
+  implementation("com.github.CymChad:BaseRecyclerViewAdapterHelper:2.9.41")
+}

+ 0 - 0
library/device/pulse/consumer-rules.pro


+ 21 - 0
library/device/pulse/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/device/pulse/src/androidTest/java/com/hzliuzhi/applet/device/pulse/ExampleInstrumentedTest.kt

@@ -0,0 +1,24 @@
+package com.hzliuzhi.applet.device.pulse
+
+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.device.pulse.test", appContext.packageName)
+  }
+}

+ 4 - 0
library/device/pulse/src/main/AndroidManifest.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+</manifest>

+ 57 - 0
library/device/pulse/src/main/java/com/hzliuzhi/applet/device/pulse/PulseEventHandler.kt

@@ -0,0 +1,57 @@
+package com.hzliuzhi.applet.device.pulse
+
+import android.app.Activity
+import com.google.gson.Gson
+import com.google.gson.JsonElement
+import com.hzliuzhi.applet.core.shared.Payload
+import com.hzliuzhi.applet.core.shared.SharedFlowHub
+import com.hzliuzhi.applet.core.shared.SharedFlowHub.callbackAs
+import com.hzliuzhi.applet.core.shared.SharedFlowHub.cast
+import com.hzliuzhi.applet.device.pulse.util.TaiYiUtil
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+class PulseEventHandle(private val activity: Activity, scope: CoroutineScope) {
+
+  private data class Pulse(
+    val userId: String?,
+  ) {
+    companion object {
+      fun formJson(element: JsonElement): Pulse {
+        return Gson().fromJson(element, Pulse::class.java)
+      }
+    }
+  }
+
+  init {
+    TaiYiUtil.init(activity.application)
+
+    SharedFlowHub.events
+      .filter { it.type.contains("pulse") }
+      .onEach { _event ->
+        when (_event.type) {
+
+          "${SharedFlowHub.WEBVIEW_BRIDGE_EVENT}:pulse" -> {
+            _event.cast<JsonElement, JsonElement>()?.also { event ->
+              val pulse = event.payload?.let { it -> Pulse.formJson(it) }
+              val callback = event.callback ?: {}
+              handlePulse(pulse) { payload ->
+                payload.copyWith(data = payload.data?.toMap()).toJson()?.also { callback(it) }
+              }
+            }
+          }
+
+          else -> _event.callbackAs<Payload<Unit>>()?.invoke(Payload.error(message = "[pulse] 未实现 ${_event.type}"))
+        }
+      }
+      .launchIn(scope)
+  }
+
+  private fun handlePulse(pulse: Pulse?, callback: ((Payload<PulseResult?>) -> Unit)) {
+    if (pulse == null || pulse.userId.isNullOrEmpty()) Payload.error<PulseResult?>(message = "[pulse] 参数解析错误").also { callback(it) }
+    else TaiYiUtil.start(activity, "six:${pulse.userId}", callback)
+  }
+}
+

+ 95 - 0
library/device/pulse/src/main/java/com/hzliuzhi/applet/device/pulse/PulseResult.kt

@@ -0,0 +1,95 @@
+package com.hzliuzhi.applet.device.pulse
+
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+import com.google.gson.stream.JsonReader
+import java.io.StringReader
+
+
+data class PulseResult(
+  @SerializedName("summary_desc")
+  val summaryLabel: SummaryLabel? = null,
+  @SerializedName("summary")
+  val summaryValue: SummaryValue? = null,
+  val time: String? = null,
+
+  val appId: String? = null,
+  val userId: String? = null,
+  val measureId: String? = null,
+  val url: String? = null,
+  val report: String = "",
+) {
+  data class SummaryLabel(
+    @SerializedName("summary")
+    val hands: List<String>? = emptyList(),
+    val left: Detail? = null,
+    val right: Detail? = null,
+  ) {
+    data class Detail(
+      val summary: List<String>? = emptyList(),
+      val guan: String? = null,
+      val chi: String? = null,
+      val cun: String? = null,
+    )
+  }
+
+  data class SummaryValue(
+    /* 弦 */
+    val xian: List<Float>? = emptyList(),
+    /* 软 */
+    val ruan: List<Float>? = emptyList(),
+
+    /* 浮 */
+    val fu: List<Float>? = emptyList(),
+    /* 沉 */
+    val chen: List<Float>? = emptyList(),
+
+    /* 滑 */
+    val hua: List<Float>? = emptyList(),
+    /* 细 */
+    val xi: List<Float>? = emptyList(),
+
+    /* 数 */
+    val shu: List<Float>? = emptyList(),
+    /* 迟 */
+    val chi: List<Float>? = emptyList(),
+
+    val kong: List<Float>? = emptyList(),
+    val shi: List<Float>? = emptyList(),
+  )
+
+
+  companion object {
+    fun fromJson(json: String) = fromJson(JsonReader(StringReader(json)))
+
+    private fun fromJson(reader: JsonReader) = try {
+      Gson().fromJson<PulseResult>(reader, PulseResult::class.java)
+    } catch (_: Throwable) {
+      null
+    }
+
+    fun toJson(result: PulseResult): String = try {
+      toMap(result).let { Gson().toJson(it) }
+    } catch (_: Throwable) {
+      ""
+    }
+
+    fun toMap(result: PulseResult): Map<String, Any?> {
+      val map = mutableMapOf<String, Any?>()
+      map["summaryLabel"] = result.summaryLabel
+      map["summaryValue"] = result.summaryValue
+
+      map["time"] = result.time
+
+      map["appId"] = result.appId
+      map["userId"] = result.userId
+      map["measureId"] = result.measureId
+      map["url"] = result.url
+      map["report"] = result.report
+      return map
+    }
+  }
+
+  fun toMap() = toMap(this)
+  override fun toString(): String = toJson(this)
+}

+ 36 - 0
library/device/pulse/src/main/java/com/hzliuzhi/applet/device/pulse/util/TaiYiResult.kt

@@ -0,0 +1,36 @@
+package com.hzliuzhi.applet.device.pulse.util
+
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+import com.hzliuzhi.applet.device.pulse.PulseResult
+
+data class TaiYiResult(
+  val measureId: String? = null,
+  @SerializedName("createTime")
+  val time: String? = null,
+  @SerializedName("pulseUrl")
+  val url: String? = null,
+  @SerializedName("handStyle")
+  val hands: String?,
+) {
+  companion object {
+    fun fromJson(json: String) = try {
+      Gson().fromJson(json, TaiYiResult::class.java)
+    } catch (_: Throwable) {
+      null
+    }
+
+    fun toResult(json: String, measureId: String, report: String): PulseResult? {
+      val result = fromJson(json)
+      return PulseResult.fromJson(report)?.let {
+        it.copy(
+          report = report,
+          url = result?.url,
+
+          time = result?.time ?: it.time,
+          measureId = result?.measureId ?: it.measureId ?: measureId
+        )
+      }
+    }
+  }
+}

+ 55 - 0
library/device/pulse/src/main/java/com/hzliuzhi/applet/device/pulse/util/TaiYiUtil.kt

@@ -0,0 +1,55 @@
+package com.hzliuzhi.applet.device.pulse.util
+
+import android.app.Activity
+import android.app.Application
+import com.hzliuzhi.applet.core.shared.Payload
+import com.hzliuzhi.applet.core.util.proxy
+import com.hzliuzhi.applet.device.pulse.PulseResult
+import com.hzliuzhi.applet.device.pulse.R
+import com.taiyi.tyusbsdk.pulse.TaiyiConfig
+import com.taiyi.tyusbsdk.pulse.TaiyiManager
+import com.taiyi.tyusbsdk.pulse.net.HttpImpl
+import com.taiyi.zhimai.ui.activity.MeasureMainActivity
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+object TaiYiUtil {
+  fun init(application: Application) = init(application, null)
+  fun init(application: Application, config: TaiyiConfig?) = TaiyiManager.getInstance().init(
+    application,
+    config ?: application.applicationContext.let { context ->
+      TaiyiConfig.getDefault(context).apply {
+        val resources = context.resources
+        skipReport = resources.getBoolean(R.bool.taiyi_skip_report)
+        delayDisconnect = resources.getInteger(R.integer.taiyi_delay_disconnect)
+        this.proxy = resources.proxy(R.array.browser_proxy_pool)
+      }
+    },
+  )
+
+  fun start(activity: Activity, userId: String, callback: ((Payload<PulseResult?>) -> Unit)) {
+    TaiyiManager.getInstance().toMeasure(
+      userId, activity, MeasureMainActivity::class.java,
+      { callback(Payload.error(code = -10, message = "脉诊未完成")) },
+      { data, measureId, report ->
+        TaiYiResult.toResult(data, measureId, report)?.also { result -> callback(Payload.data(result, message = "脉诊已完成 ($measureId)")) }
+      }
+    )
+  }
+
+  fun getReportUrlSync(measureId: String) = TaiyiManager.getInstance().getUrl(measureId)
+  suspend fun getReportUrlUrl(measureId: String) {
+    suspendCancellableCoroutine { cont ->
+      TaiyiManager.getInstance().getAsyncUrl(measureId, object : HttpImpl<String> {
+        override fun showError(message: String?) {
+          cont.resumeWithException(Exception(message))
+        }
+
+        override fun showResponse(url: String) {
+          cont.resume(url)
+        }
+      })
+    }
+  }
+}

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff