feat: webapp multi-tab JS bridge and multi-WebView management
This commit is contained in:
@@ -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';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 标签面板:列出所有标签,点击切换,× 关闭
|
// 标签面板:列出所有标签,点击切换,× 关闭
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 销毁全部内容 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 引导入口留后续)
|
// 检测通知使用权是否已授予,未授予时打日志提示(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()) {
|
|
||||||
val active = tabs.firstOrNull { it.active }
|
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
// goBack() 当前为占位 false,实际回退逻辑留 WebView 导航接入
|
if (!webAppHost.goBack()) {
|
||||||
// goBack() is a placeholder returning false; real navigation lands later
|
bridge.closeWebApp(active.id)
|
||||||
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回已打开标签列表 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 卡)
|
// 返回首页卡片目录 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user