feat: webapp multi-tab JS bridge and multi-WebView management
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
// 销毁全部内容 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
|
||||
|
||||
Reference in New Issue
Block a user