Pārlūkot izejas kodu

添加 webview 版本更新功能

cc12458 1 mēnesi atpakaļ
vecāks
revīzija
23d7533d72
20 mainītis faili ar 788 papildinājumiem un 15 dzēšanām
  1. 2 1
      .gitignore
  2. 5 0
      app/build.gradle.kts
  3. 1 0
      app/src/main/java/com/hzliuzhi/applet/container/navigation/RouteExtra.kt
  4. 4 0
      gradle/libs.versions.toml
  5. 3 0
      library/browser/build.gradle.kts
  6. 6 0
      library/browser/src/main/AndroidManifest.xml
  7. 8 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/navigation/Route.kt
  8. 10 7
      library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/KioskScreen.kt
  9. 275 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/UpdateScreen.kt
  10. 19 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateController.kt
  11. 34 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdatePackage.kt
  12. 66 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateSource.kt
  13. 12 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateState.kt
  14. 97 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/UpdateUtil.kt
  15. 188 0
      library/browser/src/main/java/com/hzliuzhi/applet/browser/update/WebViewUpdate.kt
  16. 0 7
      library/browser/src/main/java/com/hzliuzhi/applet/browser/webview/WebViewController.kt
  17. 8 0
      library/browser/src/main/res/values/update.xml
  18. BIN
      local-repo/com/norman/webviewup/core/0.1.1-alpha.01/core-0.1.1-alpha.01.aar
  19. 37 0
      local-repo/com/norman/webviewup/core/0.1.1-alpha.01/core-0.1.1-alpha.01.pom
  20. 13 0
      local-repo/com/norman/webviewup/core/maven-metadata.xml

+ 2 - 1
.gitignore

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

+ 5 - 0
app/build.gradle.kts

@@ -48,6 +48,11 @@ android {
       matchingFallbacks += listOf("aio-test", "aio", "debug")
     }
   }
+
+  androidResources {
+    noCompress.add("apk")
+  }
+
   applicationVariants.all {
     outputs.all {
       val appName = "six" // 你的自定义名称

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

@@ -10,6 +10,7 @@ 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

+ 4 - 0
gradle/libs.versions.toml

@@ -41,6 +41,10 @@ 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" }

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

@@ -56,5 +56,8 @@ dependencies {
   implementation(libs.nanohttpd)
   implementation(libs.okhttp)
 
+  implementation(libs.webviewup.core)
+  implementation(libs.webviewup.version)
+
   implementation("com.google.accompanist:accompanist-permissions:0.37.3")
 }

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

@@ -13,4 +13,10 @@
       android:usesCleartextTraffic="true">
 
   </application>
+
+  <queries>
+    <package android:name="com.google.android.webview" />
+    <package android:name="com.android.webview" />
+    <package android:name="com.android.chrome" />
+  </queries>
 </manifest>

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

@@ -1,10 +1,13 @@
 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
@@ -16,6 +19,8 @@ sealed class BrowserRoute {
 
   @Serializable
   data class Kiosk(val url: String? = "") : BrowserRoute()
+  @Serializable
+  data object Update : BrowserRoute()
 }
 
 fun NavGraphBuilder.browser(navController: NavController) {
@@ -27,4 +32,7 @@ fun NavGraphBuilder.browser(navController: NavController) {
     val route = backStackEntry.toRoute<BrowserRoute.Kiosk>()
     KioskScreen(content = WebContent.Url(url = route.url ?: "localhost"))
   }
+  composable<BrowserRoute.Update> {
+    UpdateScreen(modifier = Modifier.fillMaxSize())
+  }
 }

+ 10 - 7
library/browser/src/main/java/com/hzliuzhi/applet/browser/ui/KioskScreen.kt

@@ -12,6 +12,7 @@ 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
@@ -39,14 +40,16 @@ fun KioskScreen(
       modifier = Modifier.zIndex(2f),
       onRefresh = { controller.navigator.reload() }
     ) {
-      WebView(
-        layoutParams = layoutParams,
-        controller = controller,
-        onCreated = { controller.connected(it, content) },
-        onDispose = { controller.unconnected(it) }
-      )
+      WebViewUpdate {
+        WebView(
+          layoutParams = layoutParams,
+          controller = controller,
+          onCreated = { controller.connected(it, content) },
+          onDispose = { controller.unconnected(it) }
+        )
 
-      ProgressBar(state.load, modifier = Modifier.zIndex(1f))
+        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)
+                )
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}

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

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

@@ -0,0 +1,188 @@
+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 {
+  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(action).isSuccess
+  }?.let { true } ?: false
+}
+
+
+@Composable
+fun rememberWebViewUpdateController(context: Context = LocalContext.current) = remember { UpdateController.init(context.applicationContext) }

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

@@ -26,13 +26,6 @@ class WebViewController(
 
   fun connected(webview: WebView, content: WebContent) {
     this.webview = webview
-
-    val context = webview.context.applicationContext
-    val appInfo = context.packageManager.getApplicationInfo(context.packageName, android.content.pm.PackageManager.GET_META_DATA)
-    val myParam = appInfo.metaData.getString("build_type_tag")
-
-    Log.d("log:build_type_tag", myParam.toString())
-
     webview.applyUserAgent()
     webview.settings.applyDefaultSettings()
     coroutineScope.launch {

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

BIN
local-repo/com/norman/webviewup/core/0.1.1-alpha.01/core-0.1.1-alpha.01.aar


+ 37 - 0
local-repo/com/norman/webviewup/core/0.1.1-alpha.01/core-0.1.1-alpha.01.pom

@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+  <modelVersion>4.0.0</modelVersion>
+  <groupId>com.norman.webviewup</groupId>
+  <artifactId>core</artifactId>
+  <version>0.1.1-alpha.01</version>
+  <packaging>aar</packaging>
+  <name>core</name>
+  <description>upgrade webview core</description>
+  <url>https://github.com/JonaNorman/WebViewUpgrade</url>
+  <licenses>
+    <license>
+      <name>MIT</name>
+      <url>https://opensource.org/licenses/MIT </url>
+    </license>
+  </licenses>
+  <dependencies>
+    <dependency>
+      <groupId>me.laoyuyu.aria</groupId>
+      <artifactId>core</artifactId>
+      <version>3.8.16</version>
+      <scope>api</scope>
+    </dependency>
+    <dependency>
+      <groupId>androidx.appcompat</groupId>
+      <artifactId>appcompat</artifactId>
+      <version>1.6.1</version>
+      <scope>implementation</scope>
+    </dependency>
+    <dependency>
+      <groupId>me.laoyuyu.aria</groupId>
+      <artifactId>core</artifactId>
+      <version>3.8.16</version>
+      <scope>implementation</scope>
+    </dependency>
+  </dependencies>
+</project>

+ 13 - 0
local-repo/com/norman/webviewup/core/maven-metadata.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<metadata>
+  <groupId>com.norman.webviewup</groupId>
+  <artifactId>core</artifactId>
+  <versioning>
+    <latest>0.1.1-alpha.01</latest>
+    <release>0.1.1-alpha.01</release>
+    <versions>
+      <version>0.1.1-alpha.01</version>
+    </versions>
+    <lastUpdated>20250625071846</lastUpdated>
+  </versioning>
+</metadata>