feat: wire MainActivity with repositories, error page, and back handling

This commit is contained in:
2026-08-16 17:03:07 +08:00
parent abed4f4e15
commit 381b9a57e7
2 changed files with 126 additions and 9 deletions
@@ -3,44 +3,90 @@ package top.yeij.hearth
import android.app.Activity import android.app.Activity
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils
import android.util.Log import android.util.Log
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowInsetsController import android.view.WindowInsetsController
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.app.NotificationManagerCompat
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.card.CardRepository
import top.yeij.hearth.media.MediaSessionSource
import top.yeij.hearth.webapp.OkHttpHttpClient
import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webview.JsBridge import top.yeij.hearth.webview.JsBridge
import top.yeij.hearth.webview.WebViewManager import top.yeij.hearth.webview.WebViewManager
import java.io.File
// HOME 桌面 activity后续 Task 挂载 WebView 与 JsBridge // HOME 桌面 activity接线各能力层:Repository、媒体监听、WebView 错误页与 Back 键
// HOME launcher activity; later tasks mount WebView and JsBridge // HOME launcher activity, wiring all capability layers: repositories, media listener,
// WebView error page, and Back key handling
class MainActivity : Activity() { class MainActivity : Activity() {
companion object { companion object {
private const val TAG = "HearthMainActivity" private const val TAG = "HearthMainActivity"
// 占位清单/目录地址,真机部署时可替换为实际服务器
// 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"
} }
private val webAppContainer = WebAppContainer()
private lateinit var mediaSource: MediaSessionSource
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate: enter immersive mode") Log.d(TAG, "onCreate: enter immersive mode")
enterImmersive() enterImmersive()
setupWebView() setupWebView()
checkNotificationAccess()
} }
// 创建 JsBridge 并挂载 WebView 桌面层 override fun onDestroy() {
// Create JsBridge and mount the WebView launcher layer mediaSource.stop()
super.onDestroy()
}
// 创建 JsBridge 并挂载 WebView 桌面层,接线所有 Repository 与媒体监听
// Create JsBridge and mount the WebView launcher layer, wiring all repositories
// and the media session listener
private fun setupWebView() { private fun setupWebView() {
val dm = resources.displayMetrics val dm = resources.displayMetrics
val darkMode = val darkMode =
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES
val cache = CacheManager(File(filesDir, "cache"))
val http = OkHttpHttpClient()
val bridge = JsBridge( val bridge = JsBridge(
deviceWidthPx = dm.widthPixels, deviceWidthPx = dm.widthPixels,
deviceHeightPx = dm.heightPixels, deviceHeightPx = dm.heightPixels,
density = dm.density, density = dm.density,
darkMode = darkMode, darkMode = darkMode,
appRepository = AppRepository(AndroidAppSource(this)), appRepository = AppRepository(AndroidAppSource(this)),
webAppRepository = WebAppRepository(http, cache, MANIFEST_URL),
cardRepository = CardRepository(http, cache, CATALOG_URL),
) )
WebViewManager(this).attach(bridge) val webView = WebViewManager(this).attach(bridge)
webView.webViewClient = object : WebViewClient() {
// 主帧加载失败时展示错误页,避免白屏
// Show an error page on main-frame load failure to avoid a blank screen
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onReceivedError(
view: WebView?,
code: Int,
description: String?,
failingUrl: String?,
) {
Log.d(TAG, "onReceivedError: code=$code desc=$description url=$failingUrl")
view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null)
}
}
mediaSource = MediaSessionSource(this)
bridge.setMediaListener(mediaSource, webView)
Log.d( Log.d(
TAG, TAG,
"setupWebView: attached WebView ${dm.widthPixels}x${dm.heightPixels}" + "setupWebView: attached WebView ${dm.widthPixels}x${dm.heightPixels}" +
@@ -48,6 +94,36 @@ class MainActivity : Activity() {
) )
} }
// 检测通知使用权是否已授予,未授予时打日志提示(UI 引导入口留后续)
// Check whether notification access is granted; log a hint when not
// (the settings UI entry is deferred to a later task)
private fun checkNotificationAccess() {
val granted = NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
Log.d(TAG, "checkNotificationAccess: notification listener enabled=$granted")
if (!granted) {
Log.d(
TAG,
"checkNotificationAccess: grant via Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"
)
}
}
// 构建加载失败错误页(转义系统错误描述,避免注入)
// Build the load-failure error page (escape the system error description)
private fun errorPage(desc: String): String {
val safe = TextUtils.htmlEncode(desc)
return """
<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body{font-family:sans-serif;display:flex;flex-direction:column;align-items:center;
justify-content:center;height:100vh;margin:0;background:transparent;color:#999}
h2{font-weight:400;color:#ccc}
p{font-size:14px;max-width:80vw;text-align:center;word-break:break-all}
</style></head><body><h2>加载失败</h2><p>$safe</p></body></html>
""".trimIndent()
}
// 进入沉浸式全屏:隐藏状态栏与导航栏,滑动可临时唤出 // 进入沉浸式全屏:隐藏状态栏与导航栏,滑动可临时唤出
// Enter immersive fullscreen: hide status/navigation bars, swipe to reveal transiently // Enter immersive fullscreen: hide status/navigation bars, swipe to reveal transiently
private fun enterImmersive() { private fun enterImmersive() {
@@ -58,11 +134,24 @@ class MainActivity : Activity() {
} }
} }
// Back 键桌面态屏蔽(webAPP 打开时由 WebAppContainer 接管) // Back 键webAPP 打开时先回退,无历史则关闭激活标签;桌面态吞掉
// Swallow Back key on desktop state (WebAppContainer takes over when a webAPP is open) // Back key: when a webAPP is open, go back first, then close the active tab if
// no history; consume the event on desktop state
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override fun onBackPressed() { override fun onBackPressed() {
// 桌面态:吞掉,不响应 val tabs = webAppContainer.tabs()
// Desktop state: consume the event, do not respond 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)
}
}
Log.d(TAG, "onBackPressed: webAPP back/close, remaining tabs=${webAppContainer.tabs().size}")
return
}
Log.d(TAG, "onBackPressed: desktop state, swallowed")
} }
} }
@@ -0,0 +1,28 @@
package top.yeij.hearth.webapp
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
// 基于 OkHttp 的 HttpClient 实现:GET 返回响应体字符串,失败/非 2xx 返回 null
// OkHttp-backed HttpClient: GET returns the response body string, null on failure or non-2xx
class OkHttpHttpClient(
private val client: OkHttpClient = OkHttpClient(),
) : HttpClient {
override fun get(url: String): String? {
return try {
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { resp ->
if (resp.isSuccessful) resp.body?.string() else null
}
} catch (e: IOException) {
Log.d(TAG, "get failed: $url -> ${e.message}")
null
}
}
companion object {
private const val TAG = "HearthHttp"
}
}