From 811e2c97168c5006d0eb6dafcc830acff156de43 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 17 Aug 2026 18:02:53 +0800 Subject: [PATCH] feat: offline package version management, long-press menu --- app/src/main/assets/h5/css/app.css | 27 ++++ app/src/main/assets/h5/js/pages/webapplist.js | 115 ++++++++++++++---- .../yeij/hearth/webapp/WebAppRepository.kt | 11 +- .../top/yeij/hearth/webapp/WebAppStorage.kt | 36 +++++- .../java/top/yeij/hearth/webview/JsBridge.kt | 32 ++++- 5 files changed, 186 insertions(+), 35 deletions(-) diff --git a/app/src/main/assets/h5/css/app.css b/app/src/main/assets/h5/css/app.css index 7efb7d0..130b3fb 100644 --- a/app/src/main/assets/h5/css/app.css +++ b/app/src/main/assets/h5/css/app.css @@ -538,6 +538,33 @@ body.webapp-active [data-page="webapplist"] { transition: width .2s ease; } +/* webapp 长按管理菜单 */ +/* webapp long-press management menu */ +.wapp-menu { + position: fixed; + z-index: 300; + min-width: 180px; + padding: 8px; + border-radius: 16px; + background: var(--glass-bg); + backdrop-filter: var(--blur-filter); + -webkit-backdrop-filter: var(--blur-filter); + border: 1px solid var(--glass-border); + box-shadow: 0 4px 20px rgba(0, 0, 0, .3); +} + +.wapp-menu-item { + padding: 10px 12px; + border-radius: 10px; + font-size: 14px; + color: var(--text); + cursor: pointer; +} + +.wapp-menu-item:active { + background: var(--glass-border); +} + #web-topbar { position: fixed; top: 12px; diff --git a/app/src/main/assets/h5/js/pages/webapplist.js b/app/src/main/assets/h5/js/pages/webapplist.js index 7594c0b..d53d7b0 100644 --- a/app/src/main/assets/h5/js/pages/webapplist.js +++ b/app/src/main/assets/h5/js/pages/webapplist.js @@ -1,5 +1,6 @@ -// H5 应用列表页:富卡片 + 在线/离线标签 + 搜索 + 长按导入离线包 -// H5 web app list page: rich cards + online/offline badge + search + long-press import +// H5 应用列表页:富卡片 + 在线/离线/可更新标签 + 搜索 + 长按菜单(更新/删除/导入/属性) +// H5 web app list page: rich cards + online/offline/updatable badge + search + +// long-press menu (update/delete/import/properties) window.loadWebAppList = async function () { const page = document.querySelector('[data-page="webapplist"]'); if (page.dataset.loaded) return; page.dataset.loaded = '1'; @@ -12,8 +13,6 @@ window.loadWebAppList = async function () { const grid = document.getElementById('web-grid'); const render = (list) => { grid.textContent = ''; - // 用 createElement + textContent/dataset 渲染,避免 innerHTML 拼接远端数据(XSS) - // Render with createElement + textContent/dataset to avoid innerHTML remote data (XSS) list.forEach(a => { const btn = document.createElement('button'); btn.className = 'wcell'; @@ -31,13 +30,10 @@ window.loadWebAppList = async function () { tag.textContent = a.offline ? '离线' : '在线'; btn.append(img, lbl, tag); btn.onclick = () => bridge.call('openWebApp', btn.dataset.id); - // 长按导入离线包 - setupImportLongPress(btn, a.id); + setupLongPress(btn, a); grid.appendChild(btn); - // 异步检查本地离线包状态,更新标签 - bridge.call('hasLocalPackage', a.id).then(has => { - if (has) tag.textContent = '离线'; - }).catch(() => {}); + // 异步检查本地离线包状态 + 版本对比,更新标签 + refreshBadge(tag, a); }); }; render(apps); @@ -47,17 +43,90 @@ window.loadWebAppList = async function () { }; }; -// 长按(600ms)导入离线包 -// Long-press (600ms) to import an offline package -function setupImportLongPress(btn, id) { +// 异步刷新列表项标签(离线 / 可更新) +// Refresh the badge asynchronously (offline / updatable) +async function refreshBadge(tag, app) { + try { + const hasLocal = await bridge.call('hasLocalPackage', app.id); + const localVer = await bridge.call('getLocalVersion', app.id); + const cloudVer = app.offlineVersion || ''; + if (hasLocal && cloudVer && cloudVer !== localVer) { + tag.textContent = '可更新'; + } else if (hasLocal) { + tag.textContent = '离线'; + } else if (app.offline) { + tag.textContent = '离线'; + } else { + tag.textContent = '在线'; + } + } catch (e) { /* 忽略 */ } +} + +// 长按(600ms)弹出管理菜单 +// Long-press (600ms) to show the management menu +function setupLongPress(btn, app) { let timer = null; - btn.addEventListener('touchstart', () => { + const start = (e) => { timer = setTimeout(() => { - bridge.call('importOfflinePackage', id).catch(() => {}); + showWebAppMenu(app, e.touches[0].clientX, e.touches[0].clientY); }, 600); - }, { passive: true }); - btn.addEventListener('touchend', () => { if (timer) clearTimeout(timer); }, { passive: true }); - btn.addEventListener('touchmove', () => { if (timer) clearTimeout(timer); }, { passive: true }); + }; + const cancel = () => { if (timer) clearTimeout(timer); }; + btn.addEventListener('touchstart', start, { passive: true }); + btn.addEventListener('touchend', cancel, { passive: true }); + btn.addEventListener('touchmove', cancel, { passive: true }); +} + +// 弹出管理菜单:更新 / 删除 / 导入 / 属性 +// Show the management menu: update / delete / import / properties +async function showWebAppMenu(app, x, y) { + document.querySelectorAll('.wapp-menu').forEach(m => m.remove()); + const hasLocal = await bridge.call('hasLocalPackage', app.id); + const localVer = await bridge.call('getLocalVersion', app.id); + const cloudVer = app.offlineVersion || ''; + + const menu = document.createElement('div'); + menu.className = 'wapp-menu'; + const addItem = (label, onClick) => { + const item = document.createElement('div'); + item.className = 'wapp-menu-item'; + item.textContent = label; + item.onclick = () => { menu.remove(); if (onClick) onClick(); }; + menu.appendChild(item); + }; + + // 更新(本地有包 + 云端版本不同) + if (hasLocal && cloudVer && cloudVer !== localVer) { + addItem('更新离线包(' + (localVer || '?') + ' → ' + cloudVer + ')', () => { + bridge.call('updateOfflinePackage', app.id).catch(() => {}); + }); + } + // 删除(本地有包) + if (hasLocal) { + addItem('删除本地包', () => { + bridge.call('deleteLocalPackage', app.id).then(() => refreshList()).catch(() => {}); + }); + } + // 导入 + addItem('导入离线包', () => { + bridge.call('importOfflinePackage', app.id).catch(() => {}); + }); + // 属性 + addItem('属性:本地 ' + (localVer || '无') + ' / 云端 ' + (cloudVer || '无'), null); + + menu.style.left = Math.min(x, window.innerWidth - 220) + 'px'; + menu.style.top = Math.min(y, window.innerHeight - 180) + 'px'; + document.body.appendChild(menu); +} + +// 刷新列表(删除/导入后重新加载) +// Refresh the list (reload after delete/import) +function refreshList() { + const page = document.querySelector('[data-page="webapplist"]'); + if (page) { + page.dataset.loaded = ''; + if (page.classList.contains('active')) window.loadWebAppList(); + } } // webapp 加载/下载进度:显示覆盖层 @@ -74,13 +143,9 @@ window.HearthEvents.webappProgress = function (id, progress) { loading.querySelector('.wl-fill').style.width = progress + '%'; }; -// 导入完成:刷新列表(离线标签更新) -// Import complete: refresh the list (badge updates) +// 导入完成:刷新列表 +// Import complete: refresh the list 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(); - } + refreshList(); }; diff --git a/app/src/main/java/top/yeij/hearth/webapp/WebAppRepository.kt b/app/src/main/java/top/yeij/hearth/webapp/WebAppRepository.kt index 1ed37ff..0419129 100644 --- a/app/src/main/java/top/yeij/hearth/webapp/WebAppRepository.kt +++ b/app/src/main/java/top/yeij/hearth/webapp/WebAppRepository.kt @@ -14,6 +14,7 @@ data class WebApp( val icon: String, val url: String, val offline: String? = null, + val offlineVersion: String? = null, ) // 网络抽象接口:Task 10 的 CardRepository 也会复用 @@ -93,10 +94,12 @@ class WebAppRepository( val name = map["name"] as? String ?: return@mapNotNull null val icon = map["icon"] as? String ?: return@mapNotNull null val url = map["url"] as? String ?: return@mapNotNull null - // offline 是对象时取其 package 字段,否则 null(纯在线) - // offline is an object -> take its package field, otherwise null (online-only) - val offline = (map["offline"] as? Map<*, *>)?.get("package") as? String - WebApp(id, name, icon, url, offline) + // offline 是对象时取其 package/version 字段,否则 null(纯在线) + // offline is an object -> take its package/version fields, otherwise null (online-only) + val offlineMap = map["offline"] as? Map<*, *> + val offline = offlineMap?.get("package") as? String + val offlineVersion = offlineMap?.get("version") as? String + WebApp(id, name, icon, url, offline, offlineVersion) } } diff --git a/app/src/main/java/top/yeij/hearth/webapp/WebAppStorage.kt b/app/src/main/java/top/yeij/hearth/webapp/WebAppStorage.kt index 8bf7df0..82e9c7f 100644 --- a/app/src/main/java/top/yeij/hearth/webapp/WebAppStorage.kt +++ b/app/src/main/java/top/yeij/hearth/webapp/WebAppStorage.kt @@ -30,9 +30,23 @@ class WebAppStorage(private val context: Context) { 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 { + // 本地离线包版本号(解压目录里的 .version 文件),无则 null + // Local offline-package version (from the .version file), null when absent + fun getLocalVersion(id: String): String? { + val f = File(appDir(id), ".version") + return if (f.exists()) f.readText().trim().takeIf { it.isNotEmpty() } else null + } + + // 删除本地离线包 + // Delete the local offline package + fun deleteLocalPackage(id: String) { + appDir(id).deleteRecursively() + Log.d(TAG, "deleteLocalPackage: id=$id") + } + + // 下载并解压离线包(记录版本号),onProgress 回调 0-100 + // Download and extract the offline package (record its version), onProgress 0-100 + fun downloadAndExtract(id: String, packageUrl: String, version: String, onProgress: (Int) -> Unit): Boolean { val tmp = File(context.cacheDir, "$id-offline.zip") return try { val request = Request.Builder().url(packageUrl).build() @@ -57,6 +71,7 @@ class WebAppStorage(private val context: Context) { } } val ok = extractZip(tmp, appDir(id)) + if (ok) writeVersion(id, version) tmp.delete() ok } catch (e: Exception) { @@ -66,14 +81,15 @@ class WebAppStorage(private val context: Context) { } } - // 手动导入离线包:从 uri 复制 zip 并解压 - // Manually import an offline package: copy the zip from uri and extract + // 手动导入离线包:从 uri 复制 zip 并解压(版本记为 manual) + // Manually import an offline package: copy the zip from uri and extract (version "manual") 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)) + if (ok) writeVersion(id, "manual") tmp.delete() ok } catch (e: Exception) { @@ -83,6 +99,16 @@ class WebAppStorage(private val context: Context) { } } + // 记录本地离线包版本号(.version 文件) + // Record the local offline-package version (a .version file) + private fun writeVersion(id: String, version: String) { + try { + File(appDir(id), ".version").writeText(version) + } catch (e: Exception) { + Log.w(TAG, "writeVersion failed: ${e.message}") + } + } + // 解压 zip 到目标目录(先清空旧目录),校验 index.html 存在 // Extract the zip into the destination (clear it first), verify index.html exists private fun extractZip(zip: File, dest: File): Boolean { diff --git a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt index 1e4f9a9..76c655b 100644 --- a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt +++ b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt @@ -150,7 +150,7 @@ class JsBridge( pushWebappProgress(id, 0) Thread { val fullUrl = resolveUrl(pkg) - st.downloadAndExtract(id, fullUrl) { p -> pushWebappProgress(id, p) } + st.downloadAndExtract(id, fullUrl, app.offlineVersion ?: "") { p -> pushWebappProgress(id, p) } val url = st.localIndexUrl(id) ?: app.url openWebAppAt(container, host, id, url, app.name) pushWebappProgress(id, 100) @@ -185,6 +185,36 @@ class JsBridge( @android.webkit.JavascriptInterface fun hasLocalPackage(id: String): Boolean = storage?.hasLocalPackage(id) ?: false + // 本地离线包版本号(无则空字符串) + // Local offline-package version (empty string when absent) + @android.webkit.JavascriptInterface + fun getLocalVersion(id: String): String = storage?.getLocalVersion(id) ?: "" + + // 删除本地离线包 + // Delete the local offline package + @android.webkit.JavascriptInterface + fun deleteLocalPackage(id: String) { + Log.d("HearthBridge", "deleteLocalPackage: id=$id") + storage?.deleteLocalPackage(id) + } + + // 强制更新离线包:删除本地后重新下载 + // Force-update the offline package: delete local then re-download + @android.webkit.JavascriptInterface + fun updateOfflinePackage(id: String) { + Log.d("HearthBridge", "updateOfflinePackage: id=$id") + val st = storage ?: return + val app = manifest().firstOrNull { it.id == id } ?: return + val pkg = app.offline ?: return + st.deleteLocalPackage(id) + pushWebappProgress(id, 0) + Thread { + val fullUrl = resolveUrl(pkg) + st.downloadAndExtract(id, fullUrl, app.offlineVersion ?: "") { p -> pushWebappProgress(id, p) } + pushWebappProgress(id, 100) + }.start() + } + // 推送 webapp 加载/下载进度给 H5(0-100) // Push webapp load/download progress to H5 (0-100) private fun pushWebappProgress(id: String, progress: Int) {