feat: webapp multi-tab JS bridge and multi-WebView management

This commit is contained in:
2026-08-16 17:13:48 +08:00
parent 381b9a57e7
commit 6ca55dbe5f
8 changed files with 419 additions and 35 deletions
@@ -34,6 +34,11 @@ window.showTopbar = function (tabs) {
document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`; document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`;
const active = tabs.find(t => t.active); const active = tabs.find(t => t.active);
document.getElementById('tb-title').textContent = active ? active.name : ''; document.getElementById('tb-title').textContent = active ? active.name : '';
// 无标签时隐藏顶栏与标签面板(回到桌面)
// Hide the topbar and tab panel when no tabs remain (back to desktop)
bar.style.display = tabs.length ? 'flex' : 'none';
const panel = document.getElementById('tab-panel');
if (panel && !tabs.length) panel.style.display = 'none';
}; };
// 标签面板:列出所有标签,点击切换,× 关闭 // 标签面板:列出所有标签,点击切换,× 关闭
+135 -18
View File
@@ -5,26 +5,32 @@ import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowInsetsController import android.view.WindowInsetsController
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.google.gson.Gson
import top.yeij.hearth.app.AndroidAppSource import top.yeij.hearth.app.AndroidAppSource
import top.yeij.hearth.app.AppRepository import top.yeij.hearth.app.AppRepository
import top.yeij.hearth.cache.CacheManager import top.yeij.hearth.cache.CacheManager
import top.yeij.hearth.card.CardRepository import top.yeij.hearth.card.CardRepository
import top.yeij.hearth.media.MediaSessionSource import top.yeij.hearth.media.MediaSessionSource
import top.yeij.hearth.webapp.OkHttpHttpClient import top.yeij.hearth.webapp.OkHttpHttpClient
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.webview.JsBridge import top.yeij.hearth.webview.JsBridge
import top.yeij.hearth.webview.WebAppHost
import top.yeij.hearth.webview.WebViewManager import top.yeij.hearth.webview.WebViewManager
import java.io.File import java.io.File
// HOME 桌面 activity,接线各能力层:Repository、媒体监听、WebView 错误页与 Back 键 // HOME 桌面 activity,接线各能力层:Repository、媒体监听、WebView webAPP 管理
// HOME launcher activity, wiring all capability layers: repositories, media listener, // HOME launcher activity, wiring all capability layers: repositories, media listener,
// WebView error page, and Back key handling // and multi-WebView webapp management
class MainActivity : Activity() { class MainActivity : Activity() {
companion object { companion object {
@@ -33,10 +39,18 @@ class MainActivity : Activity() {
// Placeholder manifest/catalog URLs; replace with the real server on device // Placeholder manifest/catalog URLs; replace with the real server on device
private const val MANIFEST_URL = "https://example.com/hearth/manifest.json" private const val MANIFEST_URL = "https://example.com/hearth/manifest.json"
private const val CATALOG_URL = "https://example.com/hearth/catalog.json" private const val CATALOG_URL = "https://example.com/hearth/catalog.json"
// 内容 WebView 顶栏预留高度(dp)
// Topbar reserved height for content WebViews (dp)
private const val TOPBAR_HEIGHT_DP = 44
} }
private val webAppContainer = WebAppContainer() private val webAppContainer = WebAppContainer()
private val gson = Gson()
private val webAppHost = WebAppHostImpl()
private lateinit var mediaSource: MediaSessionSource private lateinit var mediaSource: MediaSessionSource
private lateinit var root: FrameLayout
private lateinit var desktopWebView: WebView
private lateinit var bridge: JsBridge
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -48,12 +62,13 @@ class MainActivity : Activity() {
override fun onDestroy() { override fun onDestroy() {
mediaSource.stop() mediaSource.stop()
webAppHost.destroyAll()
super.onDestroy() super.onDestroy()
} }
// 创建 JsBridge 并挂载 WebView 桌面层,接线所有 Repository 与媒体监听 // 创建 JsBridge 并挂载桌面 WebView(底层),接线所有 Repository 与媒体监听
// Create JsBridge and mount the WebView launcher layer, wiring all repositories // Create JsBridge and mount the desktop WebView (bottom layer), wiring all
// and the media session listener // repositories and the media session listener
private fun setupWebView() { private fun setupWebView() {
val dm = resources.displayMetrics val dm = resources.displayMetrics
val darkMode = val darkMode =
@@ -61,7 +76,7 @@ class MainActivity : Activity() {
Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES
val cache = CacheManager(File(filesDir, "cache")) val cache = CacheManager(File(filesDir, "cache"))
val http = OkHttpHttpClient() val http = OkHttpHttpClient()
val bridge = JsBridge( bridge = JsBridge(
deviceWidthPx = dm.widthPixels, deviceWidthPx = dm.widthPixels,
deviceHeightPx = dm.heightPixels, deviceHeightPx = dm.heightPixels,
density = dm.density, density = dm.density,
@@ -69,9 +84,13 @@ class MainActivity : Activity() {
appRepository = AppRepository(AndroidAppSource(this)), appRepository = AppRepository(AndroidAppSource(this)),
webAppRepository = WebAppRepository(http, cache, MANIFEST_URL), webAppRepository = WebAppRepository(http, cache, MANIFEST_URL),
cardRepository = CardRepository(http, cache, CATALOG_URL), cardRepository = CardRepository(http, cache, CATALOG_URL),
webAppContainer = webAppContainer,
webAppHost = webAppHost,
) )
val webView = WebViewManager(this).attach(bridge) root = FrameLayout(this)
webView.webViewClient = object : WebViewClient() { setContentView(root)
desktopWebView = WebViewManager(this).attach(bridge, root)
desktopWebView.webViewClient = object : WebViewClient() {
// 主帧加载失败时展示错误页,避免白屏 // 主帧加载失败时展示错误页,避免白屏
// Show an error page on main-frame load failure to avoid a blank screen // Show an error page on main-frame load failure to avoid a blank screen
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
@@ -86,7 +105,7 @@ class MainActivity : Activity() {
} }
} }
mediaSource = MediaSessionSource(this) mediaSource = MediaSessionSource(this)
bridge.setMediaListener(mediaSource, webView) bridge.setMediaListener(mediaSource, desktopWebView)
Log.d( Log.d(
TAG, TAG,
"setupWebView: attached WebView ${dm.widthPixels}x${dm.heightPixels}" + "setupWebView: attached WebView ${dm.widthPixels}x${dm.heightPixels}" +
@@ -94,6 +113,109 @@ class MainActivity : Activity() {
) )
} }
// webAPP 内容 WebView 宿主实现:管理 id → WebView 映射、可见性、导航、顶栏同步
// Webapp content WebView host implementation: manage id -> WebView mapping,
// visibility, navigation, and topbar sync
private inner class WebAppHostImpl : WebAppHost {
private val contentWebViews = mutableMapOf<String, WebView>()
private var currentVisibleId: String? = null
private val topBarHeightPx by lazy {
(TOPBAR_HEIGHT_DP * resources.displayMetrics.density).toInt()
}
override fun openWebView(id: String, url: String) {
if (contentWebViews.containsKey(id)) {
switchWebView(id)
return
}
val webView = WebViewManager(this@MainActivity).createContentWebView()
webView.webViewClient = object : WebViewClient() {
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onReceivedError(
view: WebView?,
code: Int,
description: String?,
failingUrl: String?,
) {
Log.d(TAG, "content onReceivedError: code=$code desc=$description url=$failingUrl")
view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null)
}
}
val params = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
params.topMargin = topBarHeightPx
contentWebViews[id] = webView
root.addView(webView, params)
webView.loadUrl(url)
switchWebView(id)
Log.d(TAG, "openWebView: id=$id url=$url topMargin=$topBarHeightPx")
}
override fun closeWebView(id: String) {
val webView = contentWebViews.remove(id) ?: return
root.removeView(webView)
webView.stopLoading()
webView.destroy()
if (currentVisibleId == id) currentVisibleId = null
Log.d(TAG, "closeWebView: id=$id remaining=${contentWebViews.size}")
}
override fun switchWebView(id: String) {
currentVisibleId = id
contentWebViews.forEach { (tabId, webView) ->
webView.visibility = if (tabId == id) View.VISIBLE else View.GONE
}
Log.d(TAG, "switchWebView: id=$id")
}
override fun goBack(): Boolean {
val webView = currentVisibleId?.let { contentWebViews[it] }
return if (webView != null && webView.canGoBack()) {
webView.goBack()
true
} else {
false
}
}
override fun goForward(): Boolean {
val webView = currentVisibleId?.let { contentWebViews[it] }
return if (webView != null && webView.canGoForward()) {
webView.goForward()
true
} else {
false
}
}
override fun reload() {
currentVisibleId?.let { contentWebViews[it] }?.reload()
}
override fun syncTabs(tabs: List<Tab>) {
val json = gson.toJson(
tabs.map { mapOf("id" to it.id, "name" to it.name, "active" to it.active) }
)
desktopWebView.post {
desktopWebView.evaluateJavascript(
"window.showTopbar && window.showTopbar($json);",
null
)
}
Log.d(TAG, "syncTabs: ${tabs.size} tabs")
}
// 销毁全部内容 WebViewActivity 退出时释放资源)
// Destroy all content WebViews (release resources on activity teardown)
fun destroyAll() {
contentWebViews.values.forEach { it.stopLoading(); it.destroy() }
contentWebViews.clear()
currentVisibleId = 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)
@@ -139,15 +261,10 @@ class MainActivity : Activity() {
// no history; consume the event on desktop state // no history; consume the event on desktop state
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override fun onBackPressed() { override fun onBackPressed() {
val tabs = webAppContainer.tabs() val active = webAppContainer.tabs().firstOrNull { it.active }
if (tabs.isNotEmpty()) { if (active != null) {
val active = tabs.firstOrNull { it.active } if (!webAppHost.goBack()) {
if (active != null) { bridge.closeWebApp(active.id)
// goBack() 当前为占位 false,实际回退逻辑留 WebView 导航接入
// goBack() is a placeholder returning false; real navigation lands later
if (!webAppContainer.goBack()) {
webAppContainer.close(active.id)
}
} }
Log.d(TAG, "onBackPressed: webAPP back/close, remaining tabs=${webAppContainer.tabs().size}") Log.d(TAG, "onBackPressed: webAPP back/close, remaining tabs=${webAppContainer.tabs().size}")
return return
@@ -1,8 +1,9 @@
package top.yeij.hearth.webapp package top.yeij.hearth.webapp
// 单个 WebView 标签:id 唯一标识,active 表示是否为当前激活标签 // 单个 WebView 标签:id 唯一标识,active 表示是否为当前激活标签name 为展示名
// Single WebView tab: id is the unique key, active marks the currently-focused tab // Single WebView tab: id is the unique key, active marks the currently-focused tab,
data class Tab(val id: String, val url: String, val active: Boolean = false) // name is the display title (from the web app manifest)
data class Tab(val id: String, val url: String, val active: Boolean = false, val name: String = "")
// 多标签状态管理(纯 Kotlin 数据层,不依赖 WebView,可 JVM 单测) // 多标签状态管理(纯 Kotlin 数据层,不依赖 WebView,可 JVM 单测)
// 真实 WebView 导航(goBack/goForward/reload)留待 Task 13 集成时接入 // 真实 WebView 导航(goBack/goForward/reload)留待 Task 13 集成时接入
@@ -11,16 +12,17 @@ data class Tab(val id: String, val url: String, val active: Boolean = false)
class WebAppContainer { class WebAppContainer {
private val tabs = mutableListOf<Tab>() private val tabs = mutableListOf<Tab>()
// 打开标签:已存在则激活它(不重复添加),否则新增并激活 // 打开标签:已存在则激活它(不重复添加),否则新增并激活name 记录展示名
// Open a tab: if it already exists just activate it, otherwise add and activate // Open a tab: if it already exists just activate it, otherwise add and activate;
fun open(id: String, url: String) { // name records the display title from the manifest
fun open(id: String, url: String, name: String = "") {
val existing = tabs.find { it.id == id } val existing = tabs.find { it.id == id }
if (existing != null) { if (existing != null) {
setActive(id) setActive(id)
return return
} }
for (i in tabs.indices) tabs[i] = tabs[i].copy(active = false) for (i in tabs.indices) tabs[i] = tabs[i].copy(active = false)
tabs.add(Tab(id, url, active = true)) tabs.add(Tab(id, url, active = true, name = name))
} }
// 关闭标签:若关闭后没有激活标签则激活第一个 // 关闭标签:若关闭后没有激活标签则激活第一个
@@ -42,6 +44,10 @@ class WebAppContainer {
// Return a defensive copy of the tab list // Return a defensive copy of the tab list
fun tabs(): List<Tab> = tabs.toList() fun tabs(): List<Tab> = tabs.toList()
// 返回当前激活标签 id,无标签或未激活返回 null
// Return the active tab id, or null when empty/none active
fun activeTabId(): String? = tabs.firstOrNull { it.active }?.id
private fun setActive(id: String) { private fun setActive(id: String) {
for (i in tabs.indices) tabs[i] = tabs[i].copy(active = tabs[i].id == id) for (i in tabs.indices) tabs[i] = tabs[i].copy(active = tabs[i].id == id)
} }
@@ -5,7 +5,9 @@ import top.yeij.hearth.app.AppRepository
import top.yeij.hearth.card.Card import top.yeij.hearth.card.Card
import top.yeij.hearth.card.CardRepository import top.yeij.hearth.card.CardRepository
import top.yeij.hearth.media.MediaSessionSource import top.yeij.hearth.media.MediaSessionSource
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.WebAppRepository import top.yeij.hearth.webapp.WebAppRepository
class JsBridge( class JsBridge(
@@ -16,6 +18,8 @@ class JsBridge(
private val appRepository: AppRepository? = null, private val appRepository: AppRepository? = null,
private val webAppRepository: WebAppRepository? = null, private val webAppRepository: WebAppRepository? = null,
private val cardRepository: CardRepository? = null, private val cardRepository: CardRepository? = null,
private val webAppContainer: WebAppContainer? = null,
private val webAppHost: WebAppHost? = null,
) { ) {
private val gson = com.google.gson.Gson() private val gson = com.google.gson.Gson()
@@ -47,6 +51,94 @@ class JsBridge(
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
fun fetchWebApps(): String = gson.toJson(webAppRepository?.fetchManifest() ?: emptyList<WebApp>()) fun fetchWebApps(): String = gson.toJson(webAppRepository?.fetchManifest() ?: emptyList<WebApp>())
// 打开指定 id 的 webAPP:从清单找 url/name → 创建内容 WebView → 记录标签 → 同步顶栏
// Open a web app by id: look up url/name from the manifest -> create content WebView
// -> record the tab -> sync the topbar
@android.webkit.JavascriptInterface
fun openWebApp(id: String) {
val repo = webAppRepository ?: return
val container = webAppContainer ?: return
val host = webAppHost ?: return
val app = repo.fetchManifest().firstOrNull { it.id == id }
if (app == null) {
Log.d("HearthBridge", "openWebApp: unknown id=$id")
return
}
host.openWebView(id, app.url)
container.open(id, app.url, app.name)
syncTabs()
Log.d("HearthBridge", "openWebApp: id=$id name=${app.name} url=${app.url}")
}
// 关闭指定 id 的标签:销毁 WebView → 移除标签 → 若剩标签激活第一个 → 同步顶栏
// Close a tab by id: destroy WebView -> remove tab -> activate first if any remain
// -> sync the topbar
@android.webkit.JavascriptInterface
fun closeWebApp(id: String) {
val container = webAppContainer ?: return
val host = webAppHost ?: return
host.closeWebView(id)
container.close(id)
container.activeTabId()?.let { host.switchWebView(it) }
syncTabs()
Log.d("HearthBridge", "closeWebApp: id=$id remaining=${container.tabs().size}")
}
// 切换激活标签并显示对应 WebView
// Switch the active tab and show its WebView
@android.webkit.JavascriptInterface
fun switchTab(id: String) {
val container = webAppContainer ?: return
val host = webAppHost ?: return
container.switchTo(id)
host.switchWebView(id)
syncTabs()
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)
}
// 当前激活标签回退
// Go back on the active tab
@android.webkit.JavascriptInterface
fun webGoBack() {
webAppHost?.goBack()
Log.d("HearthBridge", "webGoBack called")
}
// 当前激活标签前进
// Go forward on the active tab
@android.webkit.JavascriptInterface
fun webGoForward() {
webAppHost?.goForward()
Log.d("HearthBridge", "webGoForward called")
}
// 当前激活标签重载
// Reload the active tab
@android.webkit.JavascriptInterface
fun webReload() {
webAppHost?.reload()
Log.d("HearthBridge", "webReload called")
}
// 将容器标签状态推送给宿主(同步桌面 H5 顶栏)
// Push the container tab state to the host (sync the desktop H5 topbar)
private fun syncTabs() {
val container = webAppContainer ?: return
val host = webAppHost ?: return
host.syncTabs(container.tabs())
}
// 返回首页卡片目录 JSON(无仓库时返回空数组;有仓库时由 fetchCatalog 保证内置 time 卡) // 返回首页卡片目录 JSON(无仓库时返回空数组;有仓库时由 fetchCatalog 保证内置 time 卡)
// Return the home card catalog JSON (empty array when no repository wired; // Return the home card catalog JSON (empty array when no repository wired;
// fetchCatalog guarantees the builtin time card when wired) // fetchCatalog guarantees the builtin time card when wired)
@@ -0,0 +1,38 @@
package top.yeij.hearth.webview
import top.yeij.hearth.webapp.Tab
// webAPP 内容 WebView 宿主接口:由 MainActivity 实现,管理多 WebView 的生命周期、
// 可见性、导航,并把标签状态同步回桌面 H5 顶栏
// Web app content WebView host interface: implemented by MainActivity, managing
// multi-WebView lifecycle, visibility, and navigation, and syncing tab state
// back to the desktop H5 topbar
interface WebAppHost {
// 创建内容 WebView 加载 url 并显示(已存在则切换到该标签)
// Create a content WebView loading url and show it (switch if already open)
fun openWebView(id: String, url: String)
// 销毁对应内容 WebView
// Destroy the corresponding content WebView
fun closeWebView(id: String)
// 切换可见内容 WebView(其它内容 WebView GONE
// Switch the visible content WebView (hide the others with GONE)
fun switchWebView(id: String)
// 当前可见 WebView 回退,无历史返回 false
// Go back on the visible WebView, false when no history
fun goBack(): Boolean
// 当前可见 WebView 前进,无历史返回 false
// Go forward on the visible WebView, false when no history
fun goForward(): Boolean
// 当前可见 WebView 重载
// Reload the visible WebView
fun reload()
// 将标签状态推送到桌面 H5 顶栏
// Push the tab state to the desktop H5 topbar
fun syncTabs(tabs: List<Tab>)
}
@@ -2,17 +2,44 @@ package top.yeij.hearth.webview
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.graphics.Color
import android.view.ViewGroup
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebSettings
import android.webkit.WebViewClient import android.webkit.WebViewClient
// WebView 工厂:统一内核配置,桌面层挂到指定父容器,内容层按需创建
// WebView factory: unified kernel config; the desktop layer attaches to a given
// parent container, content layers are created on demand
class WebViewManager(private val activity: Activity) { class WebViewManager(private val activity: Activity) {
// 创建桌面 WebView(底层 H5)挂到 parent,加载内置 index.html
// Create the desktop WebView (bottom H5 layer) under parent, load bundled index.html
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
fun attach(bridge: JsBridge): WebView { fun attach(bridge: JsBridge, parent: ViewGroup): WebView {
val webView = WebView(activity) val webView = WebView(activity)
// 透明背景,露出原生壁纸 webView.setBackgroundColor(Color.TRANSPARENT)
// Transparent background to reveal native wallpaper configure(webView)
webView.setBackgroundColor(android.graphics.Color.TRANSPARENT) webView.addJavascriptInterface(bridge, "HearthBridge")
webView.webViewClient = WebViewClient()
webView.loadUrl("file:///android_asset/h5/index.html")
parent.addView(webView, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
return webView
}
// 创建内容 WebView(webAPP 标签页),仅配置不加载、不挂载(由宿主管理)
// Create a content WebView (webapp tab), configured but not loaded/attached
// (lifecycle is managed by the host)
@SuppressLint("SetJavaScriptEnabled")
fun createContentWebView(): WebView {
val webView = WebView(activity)
webView.setBackgroundColor(Color.WHITE)
configure(webView)
return webView
}
// 统一内核配置:JS、宽视口、DOM 存储,禁用缩放
// Common kernel config: JS, wide viewport, DOM storage, zoom disabled
@SuppressLint("SetJavaScriptEnabled")
private fun configure(webView: WebView) {
webView.settings.apply { webView.settings.apply {
javaScriptEnabled = true javaScriptEnabled = true
useWideViewPort = true useWideViewPort = true
@@ -20,10 +47,5 @@ class WebViewManager(private val activity: Activity) {
setSupportZoom(false) setSupportZoom(false)
domStorageEnabled = true domStorageEnabled = true
} }
webView.addJavascriptInterface(bridge, "HearthBridge")
webView.webViewClient = WebViewClient()
webView.loadUrl("file:///android_asset/h5/index.html")
activity.setContentView(webView)
return webView
} }
} }
@@ -58,4 +58,20 @@ class WebAppContainerTest {
c.close("b") c.close("b")
assertEquals("a", c.tabs().first { it.active }.id) assertEquals("a", c.tabs().first { it.active }.id)
} }
@Test
fun open_withName_recordsDisplayName() {
val c = WebAppContainer()
c.open("a", "http://x/a", "云音乐")
assertEquals("云音乐", c.tabs()[0].name)
}
@Test
fun activeTabId_returnsActiveId_orNullWhenEmpty() {
val c = WebAppContainer()
assertEquals(null, c.activeTabId())
c.open("a", "http://x/a")
c.open("b", "http://x/b")
assertEquals("b", c.activeTabId())
}
} }
@@ -1,12 +1,35 @@
package top.yeij.hearth.webview package top.yeij.hearth.webview
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import top.yeij.hearth.card.CardRepository import top.yeij.hearth.card.CardRepository
import top.yeij.hearth.cache.CacheManager import top.yeij.hearth.cache.CacheManager
import top.yeij.hearth.webapp.HttpClient import top.yeij.hearth.webapp.HttpClient
import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository
import java.io.File import java.io.File
// 记录宿主方法调用,用于验证 JsBridge → WebAppHost 的接线
// Record host method calls to verify JsBridge -> WebAppHost wiring
private class FakeHost : WebAppHost {
var openedId: String? = null
var openedUrl: String? = null
val closedIds = mutableListOf<String>()
var switchedId: String? = null
var syncedTabs: List<Tab>? = null
var backCalls = 0
override fun openWebView(id: String, url: String) { openedId = id; openedUrl = url }
override fun closeWebView(id: String) { closedIds.add(id) }
override fun switchWebView(id: String) { switchedId = id }
override fun goBack(): Boolean { backCalls++; return false }
override fun goForward(): Boolean = false
override fun reload() {}
override fun syncTabs(tabs: List<Tab>) { syncedTabs = tabs }
}
class JsBridgeTest { class JsBridgeTest {
@Test @Test
fun getDeviceInfo_returnsJsonWithDarkMode() { fun getDeviceInfo_returnsJsonWithDarkMode() {
@@ -38,4 +61,69 @@ class JsBridgeTest {
assertTrue(json.contains("\"id\":\"time\"")) assertTrue(json.contains("\"id\":\"time\""))
dir.deleteRecursively() dir.deleteRecursively()
} }
@Test
fun listTabs_withContainer_returnsJsonWithNameAndActive() {
val container = WebAppContainer()
container.open("a", "http://x/a", "云音乐")
val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true, webAppContainer = container)
val json = bridge.listTabs()
assertTrue(json.contains("\"id\":\"a\""))
assertTrue(json.contains("\"name\":\"云音乐\""))
assertTrue(json.contains("\"active\":true"))
}
@Test
fun listTabs_withoutContainer_returnsEmptyArray() {
val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true)
assertTrue(bridge.listTabs() == "[]")
}
@Test
fun openWebApp_findsUrlAndName_opensTabAndSyncs() {
val dir = File(System.getProperty("java.io.tmpdir"), "jb-open")
val http = object : HttpClient {
override fun get(url: String) = """{"apps":[{"id":"a","name":"云音乐","icon":"i","url":"http://x/a"}]}"""
}
val repo = WebAppRepository(http, CacheManager(dir), "http://fake")
val container = WebAppContainer()
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppRepository = repo, webAppContainer = container, webAppHost = host,
)
bridge.openWebApp("a")
assertEquals("a", host.openedId)
assertEquals("http://x/a", host.openedUrl)
assertEquals(1, container.tabs().size)
assertEquals("云音乐", container.tabs()[0].name)
assertEquals(1, host.syncedTabs?.size)
dir.deleteRecursively()
}
@Test
fun closeWebApp_destroysWebView_removesTab_andSyncs() {
val container = WebAppContainer()
container.open("a", "http://x/a", "云音乐")
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppContainer = container, webAppHost = host,
)
bridge.closeWebApp("a")
assertTrue(host.closedIds.contains("a"))
assertTrue(container.tabs().isEmpty())
assertEquals(0, host.syncedTabs?.size)
}
@Test
fun webGoBack_delegatesToHost() {
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppContainer = WebAppContainer(), webAppHost = host,
)
bridge.webGoBack()
assertEquals(1, host.backCalls)
}
} }