feat: offline package download/import, load progress, local-first open

This commit is contained in:
2026-08-17 13:11:32 +08:00
parent 010076ff28
commit 49b84f83ad
6 changed files with 347 additions and 10 deletions
@@ -10,6 +10,7 @@ import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebApp
import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webapp.WebAppStorage
// 通知使用权访问接口:由 MainActivity 实现,JsBridge 通过它检查/申请授权,
// 保持 JsBridge 可 JVM 单测(不直接依赖 Context/NotificationManagerCompat
@@ -74,9 +75,19 @@ class JsBridge(
// Tab panel callback implemented by MainActivity: shows a native tab panel
// (PopupWindow above the content WebView, no reserved height)
private val onShowTabPanel: (() -> Unit)? = null,
// 离线包存储(下载/解压/导入/本地检查)
// Offline-package storage (download/extract/import/local check)
private val storage: WebAppStorage? = null,
// 手动导入离线包回调:由 MainActivity 实现(SAF 选择 zip 后导入)
// Manual offline-package import callback implemented by MainActivity (SAF pick + import)
private val onImportOfflinePackage: ((String) -> Unit)? = null,
) {
private val gson = com.google.gson.Gson()
// 桌面 WebView 引用(媒体/进度推送用),setMediaListener 时保存
// Desktop WebView reference (for media/progress push), saved in setMediaListener
private var desktopWebViewRef: android.webkit.WebView? = null
// 清单内存缓存:openWebApp 首次拉取后缓存,后续复用避免重复 I/O
// In-memory manifest cache: cached after first fetch to avoid repeated I/O
@Volatile
@@ -114,9 +125,9 @@ class JsBridge(
return gson.toJson(apps)
}
// 打开指定 id 的 webAPP从清单缓存找 url/name → 记录标签 → 主线程创建内容 WebView → 同步顶栏
// Open a web app by id: look up url/name from the cached manifest -> record the tab
// -> create content WebView on main thread -> sync the topbar
// 打开指定 id 的 webAPP本地离线包优先 → 有离线包则下载解压(带进度)→ 否则远程加载
// Open a web app by id: prefer the local offline package -> download+extract (with
// progress) if it has an offline package -> otherwise load the remote URL
@android.webkit.JavascriptInterface
fun openWebApp(id: String) {
val container = webAppContainer ?: return
@@ -126,14 +137,72 @@ class JsBridge(
Log.d("HearthBridge", "openWebApp: unknown id=$id")
return
}
// 状态变更与 View 变更统一在主线程串行,避免与 onBackPressed/listTabs 并发读写
// Mutate tab state and view on the main thread together, serializing access
val st = storage
// 1. 本地离线包优先(服务端删除后仍可用)
val localUrl = st?.localIndexUrl(id)
if (localUrl != null) {
openWebAppAt(container, host, id, localUrl, app.name)
return
}
// 2. 有离线包 → 后台下载解压(推送进度),完成后加载本地;失败回退远程
val pkg = app.offline
if (pkg != null && st != null) {
pushWebappProgress(id, 0)
Thread {
val fullUrl = resolveUrl(pkg)
st.downloadAndExtract(id, fullUrl) { p -> pushWebappProgress(id, p) }
val url = st.localIndexUrl(id) ?: app.url
openWebAppAt(container, host, id, url, app.name)
pushWebappProgress(id, 100)
}.start()
return
}
// 3. 无离线包 → 远程加载(WebView 自带加载进度)
openWebAppAt(container, host, id, app.url, app.name)
}
// 在主线程打开指定 url 的 webAPP 标签
// Open a webapp tab at the given url on the main thread
private fun openWebAppAt(container: WebAppContainer, host: WebAppHost, id: String, url: String, name: String) {
onMain {
container.open(id, app.url, app.name)
host.openWebView(id, app.url)
container.open(id, url, name)
host.openWebView(id, url)
host.syncTabs(container.tabs())
}
Log.d("HearthBridge", "openWebApp: id=$id name=${app.name} url=${app.url}")
Log.d("HearthBridge", "openWebApp: id=$id url=$url")
}
// 手动导入离线包(由 MainActivity 触发 SAF 选择 zip
// Manually import an offline package (MainActivity triggers SAF to pick a zip)
@android.webkit.JavascriptInterface
fun importOfflinePackage(id: String) {
Log.d("HearthBridge", "importOfflinePackage: id=$id")
onImportOfflinePackage?.invoke(id)
}
// 本地是否已有离线包(用于列表显示「离线」标签)
// Whether a local offline package exists (for the list's "offline" badge)
@android.webkit.JavascriptInterface
fun hasLocalPackage(id: String): Boolean = storage?.hasLocalPackage(id) ?: false
// 推送 webapp 加载/下载进度给 H50-100
// Push webapp load/download progress to H5 (0-100)
private fun pushWebappProgress(id: String, progress: Int) {
val wv = desktopWebViewRef ?: return
wv.post {
wv.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappProgress('$id', $progress);",
null
)
}
}
// 离线包路径解析:绝对 URL 直接用,相对路径拼服务器根
// Resolve the offline-package path: absolute URL as-is, relative joined to the server root
private fun resolveUrl(relative: String): String {
if (relative.startsWith("http://") || relative.startsWith("https://")) return relative
val base = serverUrl?.getServerUrl()?.substringBeforeLast('/') ?: return relative
return "$base/$relative"
}
// 关闭指定 id 的标签:移除标签 → 主线程销毁 WebView → 若剩标签激活第一个 → 同步顶栏
@@ -327,6 +396,7 @@ class JsBridge(
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
mediaSourceRef = source
desktopWebViewRef = webView
source.start { infos -> pushMedia(infos, webView) }
}