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
+46
View File
@@ -492,6 +492,52 @@ body.webapp-active [data-page="webapplist"] {
visibility: hidden; visibility: hidden;
} }
/* webapp 加载/下载进度覆盖层 */
/* webapp load/download progress overlay */
#webapp-loading {
position: fixed;
inset: 0;
z-index: 200;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, .4);
}
#webapp-loading.show { display: flex; }
#webapp-loading .wl-box {
min-width: 220px;
padding: 20px 24px;
border-radius: 16px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
display: flex;
flex-direction: column;
gap: 12px;
}
#webapp-loading .wl-text {
font-size: 14px;
color: var(--text);
text-align: center;
}
#webapp-loading .wl-bar {
height: 4px;
border-radius: 999px;
background: var(--glass-border);
overflow: hidden;
}
#webapp-loading .wl-fill {
height: 100%;
width: 0;
border-radius: 999px;
background: var(--accent);
transition: width .2s ease;
}
#web-topbar { #web-topbar {
position: fixed; position: fixed;
top: 12px; top: 12px;
+6
View File
@@ -11,6 +11,12 @@
</head> </head>
<body> <body>
<div id="wallpaper-dim"></div> <div id="wallpaper-dim"></div>
<div id="webapp-loading">
<div class="wl-box">
<div class="wl-text">加载中…</div>
<div class="wl-bar"><div class="wl-fill"></div></div>
</div>
</div>
<div class="app"> <div class="app">
<aside class="rail" id="rail"> <aside class="rail" id="rail">
<div class="rail-clock" id="rail-clock">14:30</div> <div class="rail-clock" id="rail-clock">14:30</div>
+46 -2
View File
@@ -1,5 +1,5 @@
// H5 应用列表页:富卡片 + 在线/离线标签 + 搜索 // H5 应用列表页:富卡片 + 在线/离线标签 + 搜索 + 长按导入离线包
// H5 web app list page: rich cards + online/offline badge + search // H5 web app list page: rich cards + online/offline badge + search + long-press import
window.loadWebAppList = async function () { window.loadWebAppList = async function () {
const page = document.querySelector('[data-page="webapplist"]'); const page = document.querySelector('[data-page="webapplist"]');
if (page.dataset.loaded) return; page.dataset.loaded = '1'; if (page.dataset.loaded) return; page.dataset.loaded = '1';
@@ -31,7 +31,13 @@ window.loadWebAppList = async function () {
tag.textContent = a.offline ? '离线' : '在线'; tag.textContent = a.offline ? '离线' : '在线';
btn.append(img, lbl, tag); btn.append(img, lbl, tag);
btn.onclick = () => bridge.call('openWebApp', btn.dataset.id); btn.onclick = () => bridge.call('openWebApp', btn.dataset.id);
// 长按导入离线包
setupImportLongPress(btn, a.id);
grid.appendChild(btn); grid.appendChild(btn);
// 异步检查本地离线包状态,更新标签
bridge.call('hasLocalPackage', a.id).then(has => {
if (has) tag.textContent = '离线';
}).catch(() => {});
}); });
}; };
render(apps); render(apps);
@@ -40,3 +46,41 @@ window.loadWebAppList = async function () {
render(apps.filter(a => a.name.toLowerCase().includes(kw))); render(apps.filter(a => a.name.toLowerCase().includes(kw)));
}; };
}; };
// 长按(600ms)导入离线包
// Long-press (600ms) to import an offline package
function setupImportLongPress(btn, id) {
let timer = null;
btn.addEventListener('touchstart', () => {
timer = setTimeout(() => {
bridge.call('importOfflinePackage', id).catch(() => {});
}, 600);
}, { passive: true });
btn.addEventListener('touchend', () => { if (timer) clearTimeout(timer); }, { passive: true });
btn.addEventListener('touchmove', () => { if (timer) clearTimeout(timer); }, { passive: true });
}
// webapp 加载/下载进度:显示覆盖层
// webapp load/download progress: show the overlay
window.HearthEvents = window.HearthEvents || {};
window.HearthEvents.webappProgress = function (id, progress) {
const loading = document.getElementById('webapp-loading');
if (!loading) return;
if (progress >= 100) {
loading.classList.remove('show');
return;
}
loading.classList.add('show');
loading.querySelector('.wl-fill').style.width = progress + '%';
};
// 导入完成:刷新列表(离线标签更新)
// Import complete: refresh the list (badge updates)
window.HearthEvents.webappImported = function (id, ok) {
if (!ok) return;
const page = document.querySelector('[data-page="webapplist"]');
if (page) {
page.dataset.loaded = '';
if (page.classList.contains('active')) window.loadWebAppList();
}
};
@@ -19,6 +19,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowInsetsController import android.view.WindowInsetsController
import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -36,6 +37,7 @@ import top.yeij.hearth.webapp.OkHttpHttpClient
import top.yeij.hearth.webapp.Tab import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebAppContainer import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webapp.WebAppStorage
import top.yeij.hearth.webview.BrightnessProvider import top.yeij.hearth.webview.BrightnessProvider
import top.yeij.hearth.webview.JsBridge import top.yeij.hearth.webview.JsBridge
import top.yeij.hearth.webview.NotificationAccessProvider import top.yeij.hearth.webview.NotificationAccessProvider
@@ -67,9 +69,15 @@ class MainActivity : Activity() {
// Global sidebar collapsed width (dp); content WebViews reserve it on the // Global sidebar collapsed width (dp); content WebViews reserve it on the
// left to avoid covering the sidebar // left to avoid covering the sidebar
private const val SIDEBAR_WIDTH_DP = 80 private const val SIDEBAR_WIDTH_DP = 80
// SAF 导入离线包的 requestCode
private const val REQUEST_IMPORT_ZIP = 1001
} }
private val webAppContainer = WebAppContainer() private val webAppContainer = WebAppContainer()
private val webAppStorage = WebAppStorage(this)
// 待导入离线包的 webapp idSAF 回调用)
// Pending webapp id for offline-package import (used in the SAF callback)
private var pendingImportId: String? = null
private val gson = Gson() private val gson = Gson()
private val webAppHost = WebAppHostImpl() private val webAppHost = WebAppHostImpl()
@@ -205,6 +213,8 @@ class MainActivity : Activity() {
serverUrl = serverUrlProvider, serverUrl = serverUrlProvider,
wallpaper = wallpaperProvider, wallpaper = wallpaperProvider,
onShowTabPanel = { showNativeTabPanel() }, onShowTabPanel = { showNativeTabPanel() },
storage = webAppStorage,
onImportOfflinePackage = { id -> startImportOfflinePackage(id) },
) )
root = FrameLayout(this) root = FrameLayout(this)
setContentView(root) setContentView(root)
@@ -273,6 +283,16 @@ class MainActivity : Activity() {
view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null) view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null)
} }
} }
webView.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
// 推送 webapp 页面加载进度给 H50-100
// Push the webapp page load progress to H5 (0-100)
desktopWebView.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappProgress('$id', $newProgress);",
null
)
}
}
val params = FrameLayout.LayoutParams( val params = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT
@@ -416,6 +436,35 @@ class MainActivity : Activity() {
// dp to px // dp to px
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt() private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
// 触发 SAF 选择 zip 导入离线包
// Trigger SAF to pick a zip for offline-package import
private fun startImportOfflinePackage(id: String) {
pendingImportId = id
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/zip"
}
startActivityForResult(intent, REQUEST_IMPORT_ZIP)
}
// SAF 结果:导入离线包,成功后通知 H5(下次点开用本地)
// SAF result: import the offline package, notify H5 on success (next open uses local)
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_IMPORT_ZIP && resultCode == Activity.RESULT_OK) {
val id = pendingImportId ?: return
val uri = data?.data ?: return
val ok = webAppStorage.importPackage(id, uri)
Log.d(TAG, "importPackage: id=$id ok=$ok")
desktopWebView.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappImported('$id', $ok);",
null
)
}
pendingImportId = null
}
// 检测通知使用权是否已授予,未授予时打日志提示(UI 引导入口留后续) // 检测通知使用权是否已授予,未授予时打日志提示(UI 引导入口留后续)
// Check whether notification access is granted; log a hint when not // Check whether notification access is granted; log a hint when not
// (the settings UI entry is deferred to a later task) // (the settings UI entry is deferred to a later task)
@@ -0,0 +1,122 @@
package top.yeij.hearth.webapp
import android.content.Context
import android.net.Uri
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
import okhttp3.OkHttpClient
import okhttp3.Request
// 离线包存储:下载/解压/手动导入 webAPP 离线包到本地目录,加载时本地优先。
// 本地已解压的离线包不依赖服务端(服务端删除后仍可加载运行)。
// Offline-package storage: download/extract/manually-import a webapp's offline package
// into the local directory; loading prefers local. A locally-extracted package does
// not depend on the server (still loads after the server removes it).
class WebAppStorage(private val context: Context) {
private val client = OkHttpClient()
private val rootDir: File by lazy { File(context.filesDir, "webapps") }
private fun appDir(id: String): File = File(rootDir, id)
// 本地是否已有离线包(解压目录含 index.html
// Whether a local offline package exists (extracted dir has index.html)
fun hasLocalPackage(id: String): Boolean =
File(appDir(id), "index.html").exists()
// 本地离线包入口 URL(file:// 绝对路径),无则 null
// Local offline-package entry URL (file:// absolute path), null when absent
fun localIndexUrl(id: String): String? =
if (hasLocalPackage(id)) "file://" + File(appDir(id), "index.html").absolutePath else null
// 下载并解压离线包,onProgress 回调 0-100
// Download and extract the offline package, onProgress reports 0-100
fun downloadAndExtract(id: String, packageUrl: String, onProgress: (Int) -> Unit): Boolean {
val tmp = File(context.cacheDir, "$id-offline.zip")
return try {
val request = Request.Builder().url(packageUrl).build()
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
Log.w(TAG, "downloadAndExtract: http ${resp.code} for $packageUrl")
return false
}
val body = resp.body ?: return false
val total = body.contentLength()
val input = body.byteStream()
FileOutputStream(tmp).use { output ->
val buf = ByteArray(8192)
var read = 0L
var n = input.read(buf)
while (n >= 0) {
output.write(buf, 0, n)
read += n
if (total > 0) onProgress(((read * 100) / total).toInt())
n = input.read(buf)
}
}
}
val ok = extractZip(tmp, appDir(id))
tmp.delete()
ok
} catch (e: Exception) {
Log.e(TAG, "downloadAndExtract failed: ${e.message}")
tmp.delete()
false
}
}
// 手动导入离线包:从 uri 复制 zip 并解压
// Manually import an offline package: copy the zip from uri and extract
fun importPackage(id: String, uri: Uri): Boolean {
val tmp = File(context.cacheDir, "$id-import.zip")
return try {
val input = context.contentResolver.openInputStream(uri) ?: return false
FileOutputStream(tmp).use { output -> input.use { it.copyTo(output) } }
val ok = extractZip(tmp, appDir(id))
tmp.delete()
ok
} catch (e: Exception) {
Log.e(TAG, "importPackage failed: ${e.message}")
tmp.delete()
false
}
}
// 解压 zip 到目标目录(先清空旧目录),校验 index.html 存在
// Extract the zip into the destination (clear it first), verify index.html exists
private fun extractZip(zip: File, dest: File): Boolean {
return try {
dest.deleteRecursively()
dest.mkdirs()
ZipInputStream(zip.inputStream()).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
// 防 zip 路径穿越:规范化名称,丢弃含 ../ 的条目
// Zip-slip guard: normalize the name, drop entries with ../
val name = entry.name.replace('\\', '/')
if (name.contains("..")) {
entry = zis.nextEntry
continue
}
val outFile = File(dest, name)
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { zis.copyTo(it) }
}
entry = zis.nextEntry
}
}
File(dest, "index.html").exists()
} catch (e: Exception) {
Log.e(TAG, "extractZip failed: ${e.message}")
false
}
}
companion object {
private const val TAG = "HearthWebAppStorage"
}
}
@@ -10,6 +10,7 @@ import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebApp import top.yeij.hearth.webapp.WebApp
import top.yeij.hearth.webapp.WebAppContainer import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webapp.WebAppStorage
// 通知使用权访问接口:由 MainActivity 实现,JsBridge 通过它检查/申请授权, // 通知使用权访问接口:由 MainActivity 实现,JsBridge 通过它检查/申请授权,
// 保持 JsBridge 可 JVM 单测(不直接依赖 Context/NotificationManagerCompat // 保持 JsBridge 可 JVM 单测(不直接依赖 Context/NotificationManagerCompat
@@ -74,9 +75,19 @@ class JsBridge(
// Tab panel callback implemented by MainActivity: shows a native tab panel // Tab panel callback implemented by MainActivity: shows a native tab panel
// (PopupWindow above the content WebView, no reserved height) // (PopupWindow above the content WebView, no reserved height)
private val onShowTabPanel: (() -> Unit)? = null, 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() 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 // 清单内存缓存:openWebApp 首次拉取后缓存,后续复用避免重复 I/O
// In-memory manifest cache: cached after first fetch to avoid repeated I/O // In-memory manifest cache: cached after first fetch to avoid repeated I/O
@Volatile @Volatile
@@ -114,9 +125,9 @@ class JsBridge(
return gson.toJson(apps) return gson.toJson(apps)
} }
// 打开指定 id 的 webAPP从清单缓存找 url/name → 记录标签 → 主线程创建内容 WebView → 同步顶栏 // 打开指定 id 的 webAPP本地离线包优先 → 有离线包则下载解压(带进度)→ 否则远程加载
// Open a web app by id: look up url/name from the cached manifest -> record the tab // Open a web app by id: prefer the local offline package -> download+extract (with
// -> create content WebView on main thread -> sync the topbar // progress) if it has an offline package -> otherwise load the remote URL
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
fun openWebApp(id: String) { fun openWebApp(id: String) {
val container = webAppContainer ?: return val container = webAppContainer ?: return
@@ -126,14 +137,72 @@ class JsBridge(
Log.d("HearthBridge", "openWebApp: unknown id=$id") Log.d("HearthBridge", "openWebApp: unknown id=$id")
return return
} }
// 状态变更与 View 变更统一在主线程串行,避免与 onBackPressed/listTabs 并发读写 val st = storage
// Mutate tab state and view on the main thread together, serializing access // 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 { onMain {
container.open(id, app.url, app.name) container.open(id, url, name)
host.openWebView(id, app.url) host.openWebView(id, url)
host.syncTabs(container.tabs()) 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 → 若剩标签激活第一个 → 同步顶栏 // 关闭指定 id 的标签:移除标签 → 主线程销毁 WebView → 若剩标签激活第一个 → 同步顶栏
@@ -327,6 +396,7 @@ class JsBridge(
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) { fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
mediaSourceRef = source mediaSourceRef = source
desktopWebViewRef = webView
source.start { infos -> pushMedia(infos, webView) } source.start { infos -> pushMedia(infos, webView) }
} }