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;
|
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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 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 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 页面加载进度给 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(
|
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 加载/下载进度给 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 → 若剩标签激活第一个 → 同步顶栏
|
// 关闭指定 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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user