diff --git a/app/src/main/assets/h5/js/webapp/topbar.js b/app/src/main/assets/h5/js/webapp/topbar.js index e48d09f..a502cec 100644 --- a/app/src/main/assets/h5/js/webapp/topbar.js +++ b/app/src/main/assets/h5/js/webapp/topbar.js @@ -34,6 +34,11 @@ window.showTopbar = function (tabs) { document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`; const active = tabs.find(t => t.active); 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'; }; // 标签面板:列出所有标签,点击切换,× 关闭 diff --git a/app/src/main/java/top/yeij/hearth/MainActivity.kt b/app/src/main/java/top/yeij/hearth/MainActivity.kt index a3ae062..2f33685 100644 --- a/app/src/main/java/top/yeij/hearth/MainActivity.kt +++ b/app/src/main/java/top/yeij/hearth/MainActivity.kt @@ -5,26 +5,32 @@ import android.content.res.Configuration import android.os.Bundle import android.text.TextUtils import android.util.Log +import android.view.View +import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowInsetsController import android.webkit.WebView import android.webkit.WebViewClient +import android.widget.FrameLayout import androidx.core.app.NotificationManagerCompat +import com.google.gson.Gson import top.yeij.hearth.app.AndroidAppSource import top.yeij.hearth.app.AppRepository import top.yeij.hearth.cache.CacheManager import top.yeij.hearth.card.CardRepository import top.yeij.hearth.media.MediaSessionSource import top.yeij.hearth.webapp.OkHttpHttpClient +import top.yeij.hearth.webapp.Tab import top.yeij.hearth.webapp.WebAppContainer import top.yeij.hearth.webapp.WebAppRepository import top.yeij.hearth.webview.JsBridge +import top.yeij.hearth.webview.WebAppHost import top.yeij.hearth.webview.WebViewManager import java.io.File -// HOME 桌面 activity,接线各能力层:Repository、媒体监听、WebView 错误页与 Back 键 +// HOME 桌面 activity,接线各能力层:Repository、媒体监听、多 WebView webAPP 管理 // 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() { companion object { @@ -33,10 +39,18 @@ class MainActivity : Activity() { // 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 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 gson = Gson() + private val webAppHost = WebAppHostImpl() 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?) { super.onCreate(savedInstanceState) @@ -48,12 +62,13 @@ class MainActivity : Activity() { override fun onDestroy() { mediaSource.stop() + webAppHost.destroyAll() super.onDestroy() } - // 创建 JsBridge 并挂载 WebView 桌面层,接线所有 Repository 与媒体监听 - // Create JsBridge and mount the WebView launcher layer, wiring all repositories - // and the media session listener + // 创建 JsBridge 并挂载桌面 WebView(底层),接线所有 Repository 与媒体监听 + // Create JsBridge and mount the desktop WebView (bottom layer), wiring all + // repositories and the media session listener private fun setupWebView() { val dm = resources.displayMetrics val darkMode = @@ -61,7 +76,7 @@ class MainActivity : Activity() { Configuration.UI_MODE_NIGHT_YES val cache = CacheManager(File(filesDir, "cache")) val http = OkHttpHttpClient() - val bridge = JsBridge( + bridge = JsBridge( deviceWidthPx = dm.widthPixels, deviceHeightPx = dm.heightPixels, density = dm.density, @@ -69,9 +84,13 @@ class MainActivity : Activity() { appRepository = AppRepository(AndroidAppSource(this)), webAppRepository = WebAppRepository(http, cache, MANIFEST_URL), cardRepository = CardRepository(http, cache, CATALOG_URL), + webAppContainer = webAppContainer, + webAppHost = webAppHost, ) - val webView = WebViewManager(this).attach(bridge) - webView.webViewClient = object : WebViewClient() { + root = FrameLayout(this) + 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 @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") @@ -86,7 +105,7 @@ class MainActivity : Activity() { } } mediaSource = MediaSessionSource(this) - bridge.setMediaListener(mediaSource, webView) + bridge.setMediaListener(mediaSource, desktopWebView) Log.d( TAG, "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() + 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) { + 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") + } + + // 销毁全部内容 WebView(Activity 退出时释放资源) + // Destroy all content WebViews (release resources on activity teardown) + fun destroyAll() { + contentWebViews.values.forEach { it.stopLoading(); it.destroy() } + contentWebViews.clear() + currentVisibleId = null + } + } + // 检测通知使用权是否已授予,未授予时打日志提示(UI 引导入口留后续) // Check whether notification access is granted; log a hint when not // (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 @Suppress("DEPRECATION") override fun onBackPressed() { - val tabs = webAppContainer.tabs() - if (tabs.isNotEmpty()) { - val active = tabs.firstOrNull { it.active } - if (active != null) { - // goBack() 当前为占位 false,实际回退逻辑留 WebView 导航接入 - // goBack() is a placeholder returning false; real navigation lands later - if (!webAppContainer.goBack()) { - webAppContainer.close(active.id) - } + val active = webAppContainer.tabs().firstOrNull { it.active } + if (active != null) { + if (!webAppHost.goBack()) { + bridge.closeWebApp(active.id) } Log.d(TAG, "onBackPressed: webAPP back/close, remaining tabs=${webAppContainer.tabs().size}") return diff --git a/app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt b/app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt index 220cded..3394530 100644 --- a/app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt +++ b/app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt @@ -1,8 +1,9 @@ package top.yeij.hearth.webapp -// 单个 WebView 标签:id 唯一标识,active 表示是否为当前激活标签 -// 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) +// 单个 WebView 标签:id 唯一标识,active 表示是否为当前激活标签,name 为展示名 +// Single WebView tab: id is the unique key, active marks the currently-focused tab, +// 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 单测) // 真实 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 { private val tabs = mutableListOf() - // 打开标签:已存在则激活它(不重复添加),否则新增并激活 - // Open a tab: if it already exists just activate it, otherwise add and activate - fun open(id: String, url: String) { + // 打开标签:已存在则激活它(不重复添加),否则新增并激活;name 记录展示名 + // Open a tab: if it already exists just activate it, otherwise add and activate; + // name records the display title from the manifest + fun open(id: String, url: String, name: String = "") { val existing = tabs.find { it.id == id } if (existing != null) { setActive(id) return } 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 fun tabs(): List = 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) { for (i in tabs.indices) tabs[i] = tabs[i].copy(active = tabs[i].id == id) } diff --git a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt index e1e0697..43fb0e3 100644 --- a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt +++ b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt @@ -5,7 +5,9 @@ import top.yeij.hearth.app.AppRepository import top.yeij.hearth.card.Card import top.yeij.hearth.card.CardRepository 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 class JsBridge( @@ -16,6 +18,8 @@ class JsBridge( 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, ) { private val gson = com.google.gson.Gson() @@ -47,6 +51,94 @@ class JsBridge( @android.webkit.JavascriptInterface fun fetchWebApps(): String = gson.toJson(webAppRepository?.fetchManifest() ?: emptyList()) + // 打开指定 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") + } + + // 返回已打开标签列表 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) + } + + // 当前激活标签回退 + // 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 卡) // Return the home card catalog JSON (empty array when no repository wired; // fetchCatalog guarantees the builtin time card when wired) diff --git a/app/src/main/java/top/yeij/hearth/webview/WebAppHost.kt b/app/src/main/java/top/yeij/hearth/webview/WebAppHost.kt new file mode 100644 index 0000000..e915b6b --- /dev/null +++ b/app/src/main/java/top/yeij/hearth/webview/WebAppHost.kt @@ -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) +} diff --git a/app/src/main/java/top/yeij/hearth/webview/WebViewManager.kt b/app/src/main/java/top/yeij/hearth/webview/WebViewManager.kt index 8811fe0..3eb81fa 100644 --- a/app/src/main/java/top/yeij/hearth/webview/WebViewManager.kt +++ b/app/src/main/java/top/yeij/hearth/webview/WebViewManager.kt @@ -2,17 +2,44 @@ package top.yeij.hearth.webview import android.annotation.SuppressLint import android.app.Activity +import android.graphics.Color +import android.view.ViewGroup import android.webkit.WebView -import android.webkit.WebSettings 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) { + // 创建桌面 WebView(底层 H5)挂到 parent,加载内置 index.html + // Create the desktop WebView (bottom H5 layer) under parent, load bundled index.html @SuppressLint("SetJavaScriptEnabled") - fun attach(bridge: JsBridge): WebView { + fun attach(bridge: JsBridge, parent: ViewGroup): WebView { val webView = WebView(activity) - // 透明背景,露出原生壁纸 - // Transparent background to reveal native wallpaper - webView.setBackgroundColor(android.graphics.Color.TRANSPARENT) + webView.setBackgroundColor(Color.TRANSPARENT) + configure(webView) + 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 { javaScriptEnabled = true useWideViewPort = true @@ -20,10 +47,5 @@ class WebViewManager(private val activity: Activity) { setSupportZoom(false) domStorageEnabled = true } - webView.addJavascriptInterface(bridge, "HearthBridge") - webView.webViewClient = WebViewClient() - webView.loadUrl("file:///android_asset/h5/index.html") - activity.setContentView(webView) - return webView } } diff --git a/app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt b/app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt index 91037db..c9a6594 100644 --- a/app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt +++ b/app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt @@ -58,4 +58,20 @@ class WebAppContainerTest { c.close("b") 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()) + } } diff --git a/app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt b/app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt index 21b12c9..05dd81c 100644 --- a/app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt +++ b/app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt @@ -1,12 +1,35 @@ package top.yeij.hearth.webview +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import top.yeij.hearth.card.CardRepository import top.yeij.hearth.cache.CacheManager 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 +// 记录宿主方法调用,用于验证 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() + var switchedId: String? = null + var syncedTabs: List? = 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) { syncedTabs = tabs } +} + class JsBridgeTest { @Test fun getDeviceInfo_returnsJsonWithDarkMode() { @@ -38,4 +61,69 @@ class JsBridgeTest { assertTrue(json.contains("\"id\":\"time\"")) 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) + } }