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
+135 -18
View File
@@ -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<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 引导入口留后续)
// 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