feat: offline package download/import, load progress, local-first open
This commit is contained in:
@@ -492,6 +492,52 @@ body.webapp-active [data-page="webapplist"] {
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
</head>
|
||||
<body>
|
||||
<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">
|
||||
<aside class="rail" id="rail">
|
||||
<div class="rail-clock" id="rail-clock">14:30</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// H5 应用列表页:富卡片 + 在线/离线标签 + 搜索
|
||||
// H5 web app list page: rich cards + online/offline badge + search
|
||||
// H5 应用列表页:富卡片 + 在线/离线标签 + 搜索 + 长按导入离线包
|
||||
// H5 web app list page: rich cards + online/offline badge + search + long-press import
|
||||
window.loadWebAppList = async function () {
|
||||
const page = document.querySelector('[data-page="webapplist"]');
|
||||
if (page.dataset.loaded) return; page.dataset.loaded = '1';
|
||||
@@ -31,7 +31,13 @@ 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);
|
||||
grid.appendChild(btn);
|
||||
// 异步检查本地离线包状态,更新标签
|
||||
bridge.call('hasLocalPackage', a.id).then(has => {
|
||||
if (has) tag.textContent = '离线';
|
||||
}).catch(() => {});
|
||||
});
|
||||
};
|
||||
render(apps);
|
||||
@@ -40,3 +46,41 @@ window.loadWebAppList = async function () {
|
||||
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.WindowInsets
|
||||
import android.view.WindowInsetsController
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
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.WebAppContainer
|
||||
import top.yeij.hearth.webapp.WebAppRepository
|
||||
import top.yeij.hearth.webapp.WebAppStorage
|
||||
import top.yeij.hearth.webview.BrightnessProvider
|
||||
import top.yeij.hearth.webview.JsBridge
|
||||
import top.yeij.hearth.webview.NotificationAccessProvider
|
||||
@@ -67,9 +69,15 @@ class MainActivity : Activity() {
|
||||
// Global sidebar collapsed width (dp); content WebViews reserve it on the
|
||||
// left to avoid covering the sidebar
|
||||
private const val SIDEBAR_WIDTH_DP = 80
|
||||
// SAF 导入离线包的 requestCode
|
||||
private const val REQUEST_IMPORT_ZIP = 1001
|
||||
}
|
||||
|
||||
private val webAppContainer = WebAppContainer()
|
||||
private val webAppStorage = WebAppStorage(this)
|
||||
// 待导入离线包的 webapp id(SAF 回调用)
|
||||
// Pending webapp id for offline-package import (used in the SAF callback)
|
||||
private var pendingImportId: String? = null
|
||||
private val gson = Gson()
|
||||
private val webAppHost = WebAppHostImpl()
|
||||
|
||||
@@ -205,6 +213,8 @@ class MainActivity : Activity() {
|
||||
serverUrl = serverUrlProvider,
|
||||
wallpaper = wallpaperProvider,
|
||||
onShowTabPanel = { showNativeTabPanel() },
|
||||
storage = webAppStorage,
|
||||
onImportOfflinePackage = { id -> startImportOfflinePackage(id) },
|
||||
)
|
||||
root = FrameLayout(this)
|
||||
setContentView(root)
|
||||
@@ -273,6 +283,16 @@ class MainActivity : Activity() {
|
||||
view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null)
|
||||
}
|
||||
}
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
// 推送 webapp 页面加载进度给 H5(0-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(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
@@ -416,6 +436,35 @@ class MainActivity : Activity() {
|
||||
// dp to px
|
||||
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 引导入口留后续)
|
||||
// Check whether notification access is granted; log a hint when not
|
||||
// (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.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 加载/下载进度给 H5(0-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) }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user