493 lines
21 KiB
Kotlin
493 lines
21 KiB
Kotlin
package top.yeij.hearth.webview
|
||
|
||
import android.util.Log
|
||
import top.yeij.hearth.app.AppRepository
|
||
import top.yeij.hearth.card.Card
|
||
import top.yeij.hearth.card.CardRepository
|
||
import top.yeij.hearth.media.MediaInfo
|
||
import top.yeij.hearth.media.MediaSessionSource
|
||
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)
|
||
// Notification-access provider implemented by MainActivity; JsBridge uses it to
|
||
// check/request access, keeping JsBridge JVM-testable (no direct Context dependency)
|
||
interface NotificationAccessProvider {
|
||
fun isGranted(): Boolean
|
||
fun requestAccess()
|
||
}
|
||
|
||
// 系统亮度访问接口:由 MainActivity 实现(Settings.System + WRITE_SETTINGS 授权)
|
||
// System brightness provider implemented by MainActivity (Settings.System + WRITE_SETTINGS)
|
||
interface BrightnessProvider {
|
||
fun getSystemBrightness(): Int
|
||
fun setSystemBrightness(value: Int)
|
||
fun canWriteSettings(): Boolean
|
||
fun requestWriteSettings()
|
||
}
|
||
|
||
// 服务器地址存储接口:由 MainActivity 实现(SharedPreferences 持久化)
|
||
// Server URL storage provider implemented by MainActivity (SharedPreferences)
|
||
interface ServerUrlProvider {
|
||
fun getServerUrl(): String
|
||
fun setServerUrl(url: String)
|
||
}
|
||
|
||
// 壁纸提供接口:由 MainActivity 实现(WallpaperManager 转 base64),
|
||
// 让壁纸进入 WebView 内部,backdrop-filter 玻璃才能模糊到壁纸
|
||
// Wallpaper provider implemented by MainActivity (WallpaperManager -> base64), so the
|
||
// wallpaper lives inside the WebView and the glass backdrop-filter can blur it
|
||
interface WallpaperProvider {
|
||
fun getWallpaperBase64(): String
|
||
}
|
||
|
||
class JsBridge(
|
||
private val deviceWidthPx: Int,
|
||
private val deviceHeightPx: Int,
|
||
private val density: Float,
|
||
private val darkMode: Boolean,
|
||
private val appRepository: AppRepository? = null,
|
||
private val webAppRepository: WebAppRepository? = null,
|
||
private val cardRepository: CardRepository? = null,
|
||
private val webAppContainer: WebAppContainer? = null,
|
||
private val webAppHost: WebAppHost? = null,
|
||
// 主线程执行器:View 操作必须切到主线程;null(单测)时同步执行
|
||
// Main-thread executor: view ops must run on main; null (unit tests) runs inline
|
||
private val postToMainThread: ((() -> Unit) -> Unit)? = null,
|
||
// 通知使用权访问接口(检查/申请授权),供设置页授权项使用
|
||
// Notification-access provider (check/request) for the settings permission item
|
||
private val notificationAccess: NotificationAccessProvider? = null,
|
||
// 系统亮度接口(读取/设置 + WRITE_SETTINGS 授权)
|
||
// System brightness provider (get/set + WRITE_SETTINGS grant)
|
||
private val brightness: BrightnessProvider? = null,
|
||
// 服务器地址存储接口
|
||
// Server URL storage provider
|
||
private val serverUrl: ServerUrlProvider? = null,
|
||
// 壁纸提供接口(base64)
|
||
// Wallpaper provider (base64)
|
||
private val wallpaper: WallpaperProvider? = null,
|
||
// 标签面板回调:由 MainActivity 实现,弹出原生标签面板(PopupWindow,在内容
|
||
// WebView 之上,不占用预留高度)
|
||
// 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,
|
||
// 导出日志回调:由 MainActivity 实现,返回导出结果描述(文件名/错误)
|
||
// Export-log callback implemented by MainActivity, returns a result description
|
||
private val onExportLog: (() -> String)? = 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
|
||
private var manifestCache: List<WebApp>? = null
|
||
|
||
@android.webkit.JavascriptInterface
|
||
fun getDeviceInfo(): String {
|
||
Log.d("HearthBridge", "getDeviceInfo called")
|
||
return gson.toJson(
|
||
mapOf(
|
||
"widthPx" to deviceWidthPx,
|
||
"heightPx" to deviceHeightPx,
|
||
"density" to density,
|
||
"darkMode" to darkMode
|
||
)
|
||
)
|
||
}
|
||
|
||
// 返回已安装应用列表 JSON(无仓库时返回空数组)
|
||
// Return installed app list JSON (empty array when no repository wired)
|
||
@android.webkit.JavascriptInterface
|
||
fun listApps(): String = appRepository?.listApps() ?: "[]"
|
||
|
||
// 启动指定包名应用,成功返回 true(无仓库时返回 false)
|
||
// Launch an app by package name, true on success (false when no repository wired)
|
||
@android.webkit.JavascriptInterface
|
||
fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false
|
||
|
||
// 返回 H5 应用清单 JSON(无仓库时返回空数组),并写入内存缓存供 openWebApp 复用
|
||
// Return the H5 web app manifest JSON (empty array when no repository wired) and
|
||
// seed the in-memory cache for openWebApp reuse
|
||
@android.webkit.JavascriptInterface
|
||
fun fetchWebApps(): String {
|
||
val apps = manifest()
|
||
return gson.toJson(apps)
|
||
}
|
||
|
||
// 打开指定 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
|
||
val host = webAppHost ?: return
|
||
val app = manifest().firstOrNull { it.id == id }
|
||
if (app == null) {
|
||
Log.d("HearthBridge", "openWebApp: unknown id=$id")
|
||
return
|
||
}
|
||
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, app.offlineVersion ?: "") { 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, url, name)
|
||
host.openWebView(id, url)
|
||
host.syncTabs(container.tabs())
|
||
}
|
||
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
|
||
|
||
// 导出日志到 Download 目录,返回结果描述(文件名或错误)
|
||
// Export the log to the Download directory, return a result description
|
||
@android.webkit.JavascriptInterface
|
||
fun exportLog(): String {
|
||
Log.d("HearthBridge", "exportLog called")
|
||
return onExportLog?.invoke() ?: "日志导出未实现"
|
||
}
|
||
|
||
// 本地离线包版本号(无则空字符串)
|
||
// 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) {
|
||
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 → 若剩标签激活第一个 → 同步顶栏
|
||
// Close a tab by id: remove tab -> destroy WebView on main thread -> activate the
|
||
// first remaining tab -> sync the topbar
|
||
@android.webkit.JavascriptInterface
|
||
fun closeWebApp(id: String) {
|
||
val container = webAppContainer ?: return
|
||
val host = webAppHost ?: return
|
||
onMain {
|
||
container.close(id)
|
||
val nextActiveId = container.activeTabId()
|
||
host.closeWebView(id)
|
||
nextActiveId?.let { host.switchWebView(it) }
|
||
host.syncTabs(container.tabs())
|
||
Log.d("HearthBridge", "closeWebApp: id=$id remaining=${container.tabs().size}")
|
||
}
|
||
}
|
||
|
||
// 切换激活标签:校验 id 存在后更新状态 → 主线程切换 WebView 可见性 → 同步顶栏
|
||
// Switch the active tab: validate id exists, update state -> switch WebView
|
||
// visibility on main thread -> sync the topbar
|
||
@android.webkit.JavascriptInterface
|
||
fun switchTab(id: String) {
|
||
val container = webAppContainer ?: return
|
||
val host = webAppHost ?: return
|
||
onMain {
|
||
if (container.switchTo(id)) {
|
||
host.switchWebView(id)
|
||
host.syncTabs(container.tabs())
|
||
}
|
||
}
|
||
Log.d("HearthBridge", "switchTab: id=$id")
|
||
}
|
||
|
||
// 返回已打开标签列表 JSON(id/name/active)
|
||
// Return the open tab list JSON (id/name/active)
|
||
@android.webkit.JavascriptInterface
|
||
fun listTabs(): String {
|
||
val container = webAppContainer ?: return "[]"
|
||
val tabs = container.tabs().map { tab ->
|
||
mapOf("id" to tab.id, "name" to tab.name, "active" to tab.active)
|
||
}
|
||
return gson.toJson(tabs)
|
||
}
|
||
|
||
// 当前激活标签回退(主线程执行 WebView 导航)
|
||
// Go back on the active tab (WebView navigation runs on main thread)
|
||
@android.webkit.JavascriptInterface
|
||
fun webGoBack() {
|
||
val host = webAppHost ?: return
|
||
onMain { host.goBack() }
|
||
Log.d("HearthBridge", "webGoBack called")
|
||
}
|
||
|
||
// 当前激活标签前进(主线程执行 WebView 导航)
|
||
// Go forward on the active tab (WebView navigation runs on main thread)
|
||
@android.webkit.JavascriptInterface
|
||
fun webGoForward() {
|
||
val host = webAppHost ?: return
|
||
onMain { host.goForward() }
|
||
Log.d("HearthBridge", "webGoForward called")
|
||
}
|
||
|
||
// 当前激活标签重载(主线程执行 WebView 导航)
|
||
// Reload the active tab (WebView navigation runs on main thread)
|
||
@android.webkit.JavascriptInterface
|
||
fun webReload() {
|
||
val host = webAppHost ?: return
|
||
onMain { host.reload() }
|
||
Log.d("HearthBridge", "webReload called")
|
||
}
|
||
|
||
// 隐藏所有内容 WebView(把前台 webapp 丢到后台,标签状态保留)
|
||
// Hide all content WebViews (send the foreground webapp to background,
|
||
// keeping the tab state) — called when the user switches pages via the sidebar
|
||
@android.webkit.JavascriptInterface
|
||
fun hideWebApps() {
|
||
val host = webAppHost ?: return
|
||
onMain { host.hideAll() }
|
||
Log.d("HearthBridge", "hideWebApps called")
|
||
}
|
||
|
||
// 弹出原生标签面板(H5 顶栏标签按钮点击时调用)
|
||
// Show the native tab panel (called when the H5 topbar tab button is tapped)
|
||
@android.webkit.JavascriptInterface
|
||
fun showTabPanel() {
|
||
Log.d("HearthBridge", "showTabPanel called")
|
||
onShowTabPanel?.invoke()
|
||
}
|
||
|
||
// 清单内存缓存:首次拉取成功后缓存,后续复用(避免 openWebApp 重复 I/O)
|
||
// 空结果(离线失败)不缓存,下次调用重试拉取,避免空清单被永久缓存
|
||
// In-memory manifest cache: cache only on a successful non-empty fetch (avoid
|
||
// repeated I/O); empty results (offline failure) are never cached so the next
|
||
// call retries instead of being permanently stuck with an empty manifest
|
||
private fun manifest(): List<WebApp> {
|
||
manifestCache?.let { return it }
|
||
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
|
||
if (apps.isNotEmpty()) manifestCache = apps
|
||
return apps
|
||
}
|
||
|
||
// 将操作派发到主线程:未注入执行器(单测)时同步执行
|
||
// Dispatch to the main thread; run inline when no executor is injected (unit tests)
|
||
private fun onMain(block: () -> Unit) {
|
||
val post = postToMainThread
|
||
if (post == null) block() else post(block)
|
||
}
|
||
|
||
// 返回首页卡片目录 JSON(无仓库时返回空数组;有仓库时由 fetchCatalog 保证内置 time 卡)
|
||
// Return the home card catalog JSON (empty array when no repository wired;
|
||
// fetchCatalog guarantees the builtin time card when wired)
|
||
@android.webkit.JavascriptInterface
|
||
fun fetchCards(): String {
|
||
val cards = cardRepository?.fetchCatalog() ?: emptyList<Card>()
|
||
Log.d("HearthBridge", "fetchCards: ${cards.size} cards")
|
||
return gson.toJson(cards)
|
||
}
|
||
|
||
// 返回通知使用权是否已授权(未接入返回 false)
|
||
// Return whether notification access is granted (false when not wired)
|
||
@android.webkit.JavascriptInterface
|
||
fun getNotificationAccess(): Boolean = notificationAccess?.isGranted() ?: false
|
||
|
||
// 跳转系统「通知使用权」设置页,让用户为媒体卡授权
|
||
// Jump to the system notification-access settings page for media-card grant
|
||
@android.webkit.JavascriptInterface
|
||
fun requestNotificationAccess() {
|
||
Log.d("HearthBridge", "requestNotificationAccess called")
|
||
notificationAccess?.requestAccess()
|
||
}
|
||
|
||
// 返回系统亮度(0-255,未接入返回 -1)
|
||
// Return system brightness (0-255, -1 when not wired)
|
||
@android.webkit.JavascriptInterface
|
||
fun getSystemBrightness(): Int = brightness?.getSystemBrightness() ?: -1
|
||
|
||
// 设置系统亮度(0-255)
|
||
// Set system brightness (0-255)
|
||
@android.webkit.JavascriptInterface
|
||
fun setSystemBrightness(value: Int) {
|
||
brightness?.setSystemBrightness(value)
|
||
}
|
||
|
||
// 是否已授予「修改系统设置」权限(WRITE_SETTINGS)
|
||
// Whether the WRITE_SETTINGS permission is granted
|
||
@android.webkit.JavascriptInterface
|
||
fun canWriteSettings(): Boolean = brightness?.canWriteSettings() ?: false
|
||
|
||
// 跳转系统「修改系统设置」授权页
|
||
// Jump to the system WRITE_SETTINGS grant page
|
||
@android.webkit.JavascriptInterface
|
||
fun requestWriteSettings() {
|
||
brightness?.requestWriteSettings()
|
||
}
|
||
|
||
// 返回 webAPP 服务器地址(未接入返回空字符串)
|
||
// Return the webAPP server URL (empty string when not wired)
|
||
@android.webkit.JavascriptInterface
|
||
fun getServerUrl(): String = serverUrl?.getServerUrl() ?: ""
|
||
|
||
// 设置 webAPP 服务器地址(持久化 + 后续拉取使用)
|
||
// Set the webAPP server URL (persisted and used by subsequent fetches)
|
||
@android.webkit.JavascriptInterface
|
||
fun setServerUrl(url: String) {
|
||
Log.d("HearthBridge", "setServerUrl: $url")
|
||
serverUrl?.setServerUrl(url)
|
||
}
|
||
|
||
// 返回壁纸 base64(data URI,未接入返回空字符串)
|
||
// Return the wallpaper base64 (data URI; empty string when not wired)
|
||
@android.webkit.JavascriptInterface
|
||
fun getWallpaper(): String = wallpaper?.getWallpaperBase64() ?: ""
|
||
|
||
// 重新拉取 webAPP 清单 + 卡片目录,返回 { count }
|
||
// Re-fetch the webAPP manifest + card catalog, return { count }
|
||
@android.webkit.JavascriptInterface
|
||
fun refreshManifest(): String {
|
||
manifestCache = null
|
||
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
|
||
if (apps.isNotEmpty()) manifestCache = apps
|
||
val cards = cardRepository?.fetchCatalog() ?: emptyList<Card>()
|
||
Log.d("HearthBridge", "refreshManifest: ${apps.size} apps, ${cards.size} cards")
|
||
return gson.toJson(mapOf("count" to apps.size))
|
||
}
|
||
|
||
// 注册媒体会话监听,回调推送给 H5(info 为 null 时推 "null")
|
||
// Register media session listener; push callbacks to H5 (push "null" when info is null)
|
||
private var mediaSourceRef: MediaSessionSource? = null
|
||
|
||
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
|
||
mediaSourceRef = source
|
||
desktopWebViewRef = webView
|
||
source.start { infos -> pushMedia(infos, webView) }
|
||
}
|
||
|
||
// H5 页面就绪后重新拉取一次媒体:修复启动时推送早于页面渲染的时序问题
|
||
// Re-pull media once the H5 page is ready: fixes the startup timing where the
|
||
// push happens before the page has rendered
|
||
fun refreshMedia() {
|
||
mediaSourceRef?.refresh()
|
||
}
|
||
|
||
// 媒体播放控制(作用于指定索引的会话,对应 H5 当前滑到的卡片)
|
||
// Media transport controls (act on the session at the given index, matching
|
||
// the card the H5 is currently showing)
|
||
@android.webkit.JavascriptInterface
|
||
fun mediaPrevious(index: Int) {
|
||
mediaSourceRef?.previous(index)
|
||
}
|
||
|
||
@android.webkit.JavascriptInterface
|
||
fun mediaPlayPause(index: Int) {
|
||
mediaSourceRef?.playPause(index)
|
||
}
|
||
|
||
@android.webkit.JavascriptInterface
|
||
fun mediaNext(index: Int) {
|
||
mediaSourceRef?.next(index)
|
||
}
|
||
|
||
// 拖动进度条 seek 到指定位置(毫秒)
|
||
// Seek to the given position (ms) when dragging the progress bar
|
||
@android.webkit.JavascriptInterface
|
||
fun mediaSeekTo(index: Int, position: Long) {
|
||
mediaSourceRef?.seekTo(index, position)
|
||
}
|
||
|
||
// 跳转到指定会话的播放界面(封面点击)
|
||
// Jump to the session's playback UI (cover tap)
|
||
@android.webkit.JavascriptInterface
|
||
fun openMediaApp(index: Int) {
|
||
mediaSourceRef?.openMediaApp(index)
|
||
}
|
||
|
||
private fun pushMedia(infos: List<MediaInfo>, webView: android.webkit.WebView) {
|
||
webView.post {
|
||
val json = gson.toJson(infos)
|
||
webView.evaluateJavascript(
|
||
"window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
|
||
null
|
||
)
|
||
}
|
||
}
|
||
}
|