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, ) { 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? = 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) { 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 // 推送 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 { manifestCache?.let { return it } val apps = webAppRepository?.fetchManifest() ?: emptyList() 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() 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() if (apps.isNotEmpty()) manifestCache = apps val cards = cardRepository?.fetchCatalog() ?: emptyList() 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, webView: android.webkit.WebView) { webView.post { val json = gson.toJson(infos) webView.evaluateJavascript( "window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);", null ) } } }