Files
Hearth/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt
T
2026-08-17 19:25:43 +08:00

493 lines
21 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 加载/下载进度给 H50-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")
}
// 返回已打开标签列表 JSONid/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
)
}
}
}