From ef59941953c95796250d8de1146f712ffe5bbc3f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 18 Aug 2026 19:09:33 +0800 Subject: [PATCH] feat: offline-package builtin icon + icon rounding; fix: async check-update, wallpaper cover scaling --- app/src/main/assets/h5/css/app.css | 4 +- app/src/main/assets/h5/js/pages/settings.js | 29 ++++--- .../main/java/top/yeij/hearth/MainActivity.kt | 20 ++++- .../yeij/hearth/webapp/WebAppRepository.kt | 6 ++ .../top/yeij/hearth/webapp/WebAppStorage.kt | 18 ++++ .../java/top/yeij/hearth/webview/JsBridge.kt | 83 ++++++++++++------- 6 files changed, 114 insertions(+), 46 deletions(-) diff --git a/app/src/main/assets/h5/css/app.css b/app/src/main/assets/h5/css/app.css index 7a01d2d..cc44691 100644 --- a/app/src/main/assets/h5/css/app.css +++ b/app/src/main/assets/h5/css/app.css @@ -462,7 +462,7 @@ body { color: var(--text); } -.cell .ic { width: 48px; height: 48px; border-radius: 12px; } +.cell .ic { width: 48px; height: 48px; border-radius: 12px; object-fit: cover; display: block; } .cell .lbl { font-size: 12px; color: var(--text-dim); text-align: center; } /* H5 应用列表页:富卡片 + 在线/离线标签 */ @@ -481,7 +481,7 @@ body { color: var(--text); } -.wcell .ic { width: 48px; height: 48px; border-radius: 12px; } +.wcell .ic { width: 48px; height: 48px; border-radius: 12px; object-fit: cover; display: block; } .wcell .lbl { font-size: 12px; color: var(--text-dim); text-align: center; } .wcell .tag { diff --git a/app/src/main/assets/h5/js/pages/settings.js b/app/src/main/assets/h5/js/pages/settings.js index 6302c29..1666131 100644 --- a/app/src/main/assets/h5/js/pages/settings.js +++ b/app/src/main/assets/h5/js/pages/settings.js @@ -226,24 +226,27 @@ function setupServerUrl(page) { }; } -// 检查更新:触发重新拉取 webAPP 清单 + 卡片目录,完成后提示 -// Check update: re-fetch the webAPP manifest + card catalog, then show a hint +// 检查更新:后台异步拉取,完成后通过 checkUpdateResult 事件回调更新提示 +// Check update: async fetch on a background thread; the result is delivered via +// the checkUpdateResult event callback function setupCheckUpdate(page) { const item = page.querySelector('#check-update-item'); const state = page.querySelector('#check-update-state'); + // 注册结果回调(原生 refreshManifest 完成后推送) + // Register the result callback (pushed by native refreshManifest when done) + window.HearthEvents = window.HearthEvents || {}; + window.HearthEvents.checkUpdateResult = function (json) { + let r = json; + try { r = typeof json === 'string' ? JSON.parse(json) : json; } catch (e) { r = {}; } + const parts = []; + if (r.added > 0) parts.push('新增 ' + r.added); + if (r.removed > 0) parts.push('移除 ' + r.removed); + if (r.changed > 0) parts.push('更新 ' + r.changed); + state.textContent = parts.length ? parts.join(',') + ' ›' : '无更新 ›'; + }; item.onclick = () => { state.textContent = '检查中…'; - bridge.call('refreshManifest').then(result => { - let r = { added: 0, removed: 0, changed: 0 }; - try { r = JSON.parse(result); } catch (e) {} - const parts = []; - if (r.added > 0) parts.push('新增 ' + r.added); - if (r.removed > 0) parts.push('移除 ' + r.removed); - if (r.changed > 0) parts.push('更新 ' + r.changed); - state.textContent = parts.length ? parts.join(',') + ' ›' : '无更新 ›'; - }).catch(() => { - state.textContent = '失败 ›'; - }); + bridge.call('refreshManifest'); // 异步,不 await 结果 }; } diff --git a/app/src/main/java/top/yeij/hearth/MainActivity.kt b/app/src/main/java/top/yeij/hearth/MainActivity.kt index b9e9b40..387fd5a 100644 --- a/app/src/main/java/top/yeij/hearth/MainActivity.kt +++ b/app/src/main/java/top/yeij/hearth/MainActivity.kt @@ -149,9 +149,25 @@ class MainActivity : Activity() { if (wm.wallpaperInfo != null) return "" val d = wm.drawable ?: return "" val dm = resources.displayMetrics - val bmp = Bitmap.createBitmap(dm.widthPixels, dm.heightPixels, Bitmap.Config.ARGB_8888) + val sw = dm.widthPixels + val sh = dm.heightPixels + val bmp = Bitmap.createBitmap(sw, sh, Bitmap.Config.ARGB_8888) val canvas = Canvas(bmp) - d.setBounds(0, 0, dm.widthPixels, dm.heightPixels) + // cover 缩放:保持宽高比铺满屏幕并居中裁剪,避免竖屏壁纸在横屏下 + // 被拉伸或底部留黑边 + // cover-scale: keep aspect ratio and center-crop to fill the screen, + // avoiding stretch or a bottom black band from portrait wallpaper on + // a landscape screen + val dw = d.intrinsicWidth + val dh = d.intrinsicHeight + if (dw > 0 && dh > 0) { + val scale = maxOf(sw.toFloat() / dw, sh.toFloat() / dh) + val w = (dw * scale).toInt() + val h = (dh * scale).toInt() + d.setBounds((sw - w) / 2, (sh - h) / 2, (sw - w) / 2 + w, (sh - h) / 2 + h) + } else { + d.setBounds(0, 0, sw, sh) + } d.draw(canvas) val out = ByteArrayOutputStream() bmp.compress(Bitmap.CompressFormat.JPEG, 80, out) 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 efbb010..96ba179 100644 --- a/app/src/main/java/top/yeij/hearth/webapp/WebAppRepository.kt +++ b/app/src/main/java/top/yeij/hearth/webapp/WebAppRepository.kt @@ -16,6 +16,7 @@ data class WebApp( val url: String, val offline: String? = null, val offlineVersion: String? = null, + val offlineIcon: String? = null, val ua: String? = null, val scale: Int? = null, ) @@ -108,6 +109,10 @@ class WebAppRepository( val offlineMap = map["offline"] as? Map<*, *> val offline = offlineMap?.get("package") as? String val offlineVersion = offlineMap?.get("version") as? String + // 离线包内图标路径(相对离线包根,如 icon.svg),本地包有图标时优先显示 + // Icon path inside the offline package (relative to its root, e.g. + // icon.svg); used in preference when the local package has the icon + val offlineIcon = offlineMap?.get("icon") as? String // ua 可选:desktop/pc 用 PC UA,其他视为自定义 UA 字符串,不指定默认平板 UA // ua optional: desktop/pc uses a desktop UA, others are custom UA strings, // absent means the default tablet UA @@ -121,6 +126,7 @@ class WebAppRepository( resolvePath(url), offline?.let { resolvePath(it) }, offlineVersion, + offlineIcon, ua, scale, ) 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 9654abf..99a769d 100644 --- a/app/src/main/java/top/yeij/hearth/webapp/WebAppStorage.kt +++ b/app/src/main/java/top/yeij/hearth/webapp/WebAppStorage.kt @@ -42,6 +42,24 @@ class WebAppStorage(private val context: Context) { return "file://" + path } + // 本地离线包内图标 URL(file:// 绝对路径),图标文件不存在则 null。 + // iconPath 是相对离线包根的路径(如 icon.svg),做路径穿越校验 + // Local offline-package icon URL (file:// absolute path), null when the icon + // file doesn't exist. iconPath is relative to the package root (e.g. icon.svg), + // with a path-traversal guard + fun localIconUrl(id: String, iconPath: String): String? { + val name = iconPath.replace('\\', '/').trimStart('/') + if (name.contains("..") || name.isEmpty()) return null + val f = File(appDir(id), name) + if (!f.isFile) return null + val path = try { + f.canonicalPath + } catch (e: Exception) { + f.absolutePath + } + return "file://" + path + } + // 本地离线包版本号(解压目录里的 .version 文件),无则 null // Local offline-package version (from the .version file), null when absent fun getLocalVersion(id: String): String? { 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 caa7c57..501bd2c 100644 --- a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt +++ b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt @@ -120,14 +120,27 @@ class JsBridge( fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false // 返回 H5 应用清单 JSON(无仓库时返回空数组),并写入内存缓存供 openWebApp 复用 + // 本地包内置图标优先:离线包内有 offlineIcon 且本地已安装时,用本地 file:// 图标 // Return the H5 web app manifest JSON (empty array when no repository wired) and - // seed the in-memory cache for openWebApp reuse + // seed the in-memory cache for openWebApp reuse. Local package icon wins: when the + // offline package declares offlineIcon and is installed locally, use the local icon @android.webkit.JavascriptInterface fun fetchWebApps(): String { - val apps = manifest() + val apps = manifest().map { app -> + resolveLocalIcon(app)?.let { app.copy(icon = it) } ?: app + } return gson.toJson(apps) } + // 解析本地离线包图标:offlineIcon 指定且本地文件存在 → file:// URL,否则 null + // Resolve the local offline-package icon: when offlineIcon is set and the local + // file exists, return its file:// URL; otherwise null + private fun resolveLocalIcon(app: WebApp): String? { + val iconPath = app.offlineIcon ?: return null + val st = storage ?: return null + return st.localIconUrl(app.id, iconPath) + } + // 打开指定 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 @@ -419,38 +432,50 @@ class JsBridge( @android.webkit.JavascriptInterface fun getWallpaper(): String = wallpaper?.getWallpaperBase64() ?: "" - // 重新拉取 webAPP 清单 + 卡片目录,返回 { added, removed, changed } - // Re-fetch the webAPP manifest + card catalog, return { added, removed, changed } + // 重新拉取 webAPP 清单 + 卡片目录(后台线程,避免阻塞 JS 线程导致卡顿), + // 完成后通过 HearthEvents.checkUpdateResult 推送 { added, removed, changed } + // Re-fetch the webAPP manifest + card catalog on a background thread (avoid + // blocking the JS thread), then push { added, removed, changed } via + // HearthEvents.checkUpdateResult @android.webkit.JavascriptInterface fun refreshManifest(): String { - val oldApps = manifestCache ?: emptyList() - manifestCache = null - val apps = webAppRepository?.fetchManifest() ?: emptyList() - if (apps.isNotEmpty()) manifestCache = apps - cardRepository?.fetchCatalog() + Thread { + val oldApps = manifestCache ?: emptyList() + manifestCache = null + val apps = webAppRepository?.fetchManifest() ?: emptyList() + if (apps.isNotEmpty()) manifestCache = apps + cardRepository?.fetchCatalog() - // 增量计算:新增 / 移除 / 更新(离线包版本或 URL/图标变化) - // Diff: added / removed / changed (offline version or URL/icon changed) - val oldIds = oldApps.map { it.id }.toSet() - val newIds = apps.map { it.id }.toSet() - val added = (newIds - oldIds).size - val removed = (oldIds - newIds).size - val changed = apps.count { new -> - val old = oldApps.find { it.id == new.id } - old != null && (old.offlineVersion != new.offlineVersion || - old.url != new.url || old.icon != new.icon) - } - Log.d("HearthBridge", "refreshManifest: added=$added removed=$removed changed=$changed (${apps.size} apps)") + // 增量计算:新增 / 移除 / 更新(离线包版本或 URL/图标变化) + // Diff: added / removed / changed (offline version or URL/icon changed) + val oldIds = oldApps.map { it.id }.toSet() + val newIds = apps.map { it.id }.toSet() + val added = (newIds - oldIds).size + val removed = (oldIds - newIds).size + val changed = apps.count { new -> + val old = oldApps.find { it.id == new.id } + old != null && (old.offlineVersion != new.offlineVersion || + old.url != new.url || old.icon != new.icon) + } + Log.d("HearthBridge", "refreshManifest: added=$added removed=$removed changed=$changed (${apps.size} apps)") - // 推送 H5 刷新列表(清 preload 缓存并重渲染) - // Push H5 to refresh the list (clear preload cache and re-render) - desktopWebViewRef?.post { - desktopWebViewRef?.evaluateJavascript( - "window.HearthEvents && window.HearthEvents.webappUpdated();", - null + val resultJson = gson.toJson( + mapOf("added" to added, "removed" to removed, "changed" to changed) ) - } - return gson.toJson(mapOf("added" to added, "removed" to removed, "changed" to changed)) + // 推送结果 + 刷新列表(清 preload 缓存并重渲染) + // Push the result + refresh the list (clear preload cache and re-render) + desktopWebViewRef?.post { + desktopWebViewRef?.evaluateJavascript( + "window.HearthEvents && window.HearthEvents.checkUpdateResult($resultJson);", + null + ) + desktopWebViewRef?.evaluateJavascript( + "window.HearthEvents && window.HearthEvents.webappUpdated();", + null + ) + } + }.start() + return "checking" } // 注册媒体会话监听,回调推送给 H5(info 为 null 时推 "null")