564 lines
25 KiB
Kotlin
564 lines
25 KiB
Kotlin
package top.yeij.hearth
|
||
|
||
import android.app.Activity
|
||
import android.app.WallpaperManager
|
||
import android.content.ContentValues
|
||
import android.content.Intent
|
||
import android.content.res.Configuration
|
||
import android.graphics.Bitmap
|
||
import android.graphics.Canvas
|
||
import android.graphics.Color
|
||
import android.graphics.drawable.GradientDrawable
|
||
import android.net.Uri
|
||
import android.os.Bundle
|
||
import android.provider.MediaStore
|
||
import android.provider.Settings
|
||
import android.text.TextUtils
|
||
import android.util.Base64
|
||
import android.util.Log
|
||
import android.view.Gravity
|
||
import android.view.View
|
||
import android.view.ViewGroup
|
||
import android.view.WindowInsets
|
||
import android.view.WindowInsetsController
|
||
import android.webkit.WebChromeClient
|
||
import android.webkit.WebView
|
||
import android.webkit.WebViewClient
|
||
import android.widget.FrameLayout
|
||
import android.widget.LinearLayout
|
||
import android.widget.PopupWindow
|
||
import android.widget.TextView
|
||
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.webapp.WebAppStorage
|
||
import top.yeij.hearth.webview.BrightnessProvider
|
||
import top.yeij.hearth.webview.JsBridge
|
||
import top.yeij.hearth.webview.NotificationAccessProvider
|
||
import top.yeij.hearth.webview.ServerUrlProvider
|
||
import top.yeij.hearth.webview.WallpaperProvider
|
||
import top.yeij.hearth.webview.WebAppHost
|
||
import top.yeij.hearth.webview.WebViewManager
|
||
import java.io.ByteArrayOutputStream
|
||
import java.io.File
|
||
|
||
// HOME 桌面 activity,接线各能力层:Repository、媒体监听、多 WebView webAPP 管理
|
||
// HOME launcher activity, wiring all capability layers: repositories, media listener,
|
||
// and multi-WebView webapp management
|
||
class MainActivity : Activity() {
|
||
|
||
companion object {
|
||
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"
|
||
// 内容 WebView 顶部预留高度(dp):默认只留顶栏(约 54dp);标签面板弹出时
|
||
// 通过 setContentTopMargin 临时增大,收起后恢复,避免一直压缩 webapp 高度
|
||
// Content WebView top reserve (dp): default only the topbar (~54dp); the tab
|
||
// panel temporarily grows it via setContentTopMargin and restores on collapse,
|
||
// avoiding permanently shrinking the webapp height
|
||
private const val TOPBAR_HEIGHT_DP = 56
|
||
// 全局侧边栏收起宽度(dp),内容 WebView 左侧预留,避免挡住侧边栏
|
||
// Global sidebar collapsed width (dp); content WebViews reserve it on the
|
||
// left to avoid covering the sidebar
|
||
private const val SIDEBAR_WIDTH_DP = 80
|
||
// SAF 导入离线包的 requestCode
|
||
private const val REQUEST_IMPORT_ZIP = 1001
|
||
}
|
||
|
||
private val webAppContainer = WebAppContainer()
|
||
private val webAppStorage = WebAppStorage(this)
|
||
// 待导入离线包的 webapp id(SAF 回调用)
|
||
// Pending webapp id for offline-package import (used in the SAF callback)
|
||
private var pendingImportId: String? = null
|
||
private val gson = Gson()
|
||
private val webAppHost = WebAppHostImpl()
|
||
|
||
// 通知使用权访问实现:检查授权状态 + 跳转系统授权页(供设置页授权项使用)
|
||
// Notification-access implementation: check grant state + jump to system settings
|
||
// (used by the settings permission item)
|
||
private val notificationAccess = object : NotificationAccessProvider {
|
||
override fun isGranted(): Boolean =
|
||
NotificationManagerCompat.getEnabledListenerPackages(this@MainActivity)
|
||
.contains(packageName)
|
||
|
||
override fun requestAccess() {
|
||
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||
}
|
||
}
|
||
|
||
// 系统亮度访问实现:读/写 Settings.System.SCREEN_BRIGHTNESS,需 WRITE_SETTINGS 授权
|
||
// System brightness implementation: read/write Settings.System.SCREEN_BRIGHTNESS,
|
||
// requiring the WRITE_SETTINGS grant
|
||
private val brightnessProvider = object : BrightnessProvider {
|
||
override fun getSystemBrightness(): Int =
|
||
Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128)
|
||
|
||
override fun setSystemBrightness(value: Int) {
|
||
Settings.System.putInt(
|
||
contentResolver,
|
||
Settings.System.SCREEN_BRIGHTNESS,
|
||
value.coerceIn(0, 255)
|
||
)
|
||
}
|
||
|
||
override fun canWriteSettings(): Boolean = Settings.System.canWrite(this@MainActivity)
|
||
|
||
override fun requestWriteSettings() {
|
||
startActivity(
|
||
Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS, Uri.parse("package:$packageName"))
|
||
)
|
||
}
|
||
}
|
||
|
||
// 服务器地址存储实现:SharedPreferences 持久化,设置时同步更新 WebAppRepository
|
||
// Server URL storage implementation: persisted in SharedPreferences, syncing the
|
||
// WebAppRepository manifest URL on change
|
||
private val serverUrlProvider = object : ServerUrlProvider {
|
||
private val prefs by lazy { getSharedPreferences("hearth", MODE_PRIVATE) }
|
||
|
||
override fun getServerUrl(): String =
|
||
prefs.getString("server_url", MANIFEST_URL) ?: MANIFEST_URL
|
||
|
||
override fun setServerUrl(url: String) {
|
||
prefs.edit().putString("server_url", url).apply()
|
||
if (::webAppRepository.isInitialized) webAppRepository.setServerUrl(url)
|
||
}
|
||
}
|
||
|
||
// 壁纸提供实现:WallpaperManager 取当前壁纸转 base64,供 H5 body 背景使用
|
||
// Wallpaper implementation: WallpaperManager -> base64 for the H5 body background
|
||
private val wallpaperProvider = object : WallpaperProvider {
|
||
override fun getWallpaperBase64(): String {
|
||
return try {
|
||
val wm = WallpaperManager.getInstance(this@MainActivity)
|
||
// 动态壁纸(Live Wallpaper)是实时渲染、无静态 Drawable,返回空让 H5 保持
|
||
// 透明透出动态壁纸;此时玻璃模糊降级为普通半透明(backdrop-filter 无内容可模糊)
|
||
// Live wallpapers render in real time with no static drawable; return empty
|
||
// so H5 stays transparent and shows the live wallpaper, with the glass blur
|
||
// degrading to plain translucency (backdrop-filter has nothing to blur)
|
||
if (wm.wallpaperInfo != null) return ""
|
||
val d = wm.drawable ?: return ""
|
||
val dm = resources.displayMetrics
|
||
val bmp = Bitmap.createBitmap(dm.widthPixels, dm.heightPixels, Bitmap.Config.ARGB_8888)
|
||
val canvas = Canvas(bmp)
|
||
d.setBounds(0, 0, dm.widthPixels, dm.heightPixels)
|
||
d.draw(canvas)
|
||
val out = ByteArrayOutputStream()
|
||
bmp.compress(Bitmap.CompressFormat.JPEG, 80, out)
|
||
bmp.recycle()
|
||
"data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||
} catch (e: Exception) {
|
||
Log.w(TAG, "getWallpaper: ${e.message}")
|
||
""
|
||
}
|
||
}
|
||
}
|
||
|
||
private lateinit var webAppRepository: WebAppRepository
|
||
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)
|
||
Log.d(TAG, "onCreate: setup webview then enter immersive mode")
|
||
// 先 setContentView 创建 DecorView,再进沉浸式;否则 window.insetsController 会
|
||
// 因 DecorView 尚未创建(null)而 NPE 崩溃
|
||
// Set content view first to create the DecorView, then enter immersive mode;
|
||
// otherwise window.insetsController throws NPE because DecorView is not created yet
|
||
setupWebView()
|
||
enterImmersive()
|
||
checkNotificationAccess()
|
||
}
|
||
|
||
override fun onDestroy() {
|
||
mediaSource.stop()
|
||
webAppHost.destroyAll()
|
||
super.onDestroy()
|
||
}
|
||
|
||
// 创建 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 =
|
||
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
|
||
Configuration.UI_MODE_NIGHT_YES
|
||
val cache = CacheManager(File(filesDir, "cache"))
|
||
val http = OkHttpHttpClient()
|
||
webAppRepository = WebAppRepository(http, cache, serverUrlProvider.getServerUrl())
|
||
bridge = JsBridge(
|
||
deviceWidthPx = dm.widthPixels,
|
||
deviceHeightPx = dm.heightPixels,
|
||
density = dm.density,
|
||
darkMode = darkMode,
|
||
appRepository = AppRepository(AndroidAppSource(this)),
|
||
webAppRepository = webAppRepository,
|
||
cardRepository = CardRepository(http, cache, CATALOG_URL),
|
||
webAppContainer = webAppContainer,
|
||
webAppHost = webAppHost,
|
||
postToMainThread = { desktopWebView.post(it) },
|
||
notificationAccess = notificationAccess,
|
||
brightness = brightnessProvider,
|
||
serverUrl = serverUrlProvider,
|
||
wallpaper = wallpaperProvider,
|
||
onShowTabPanel = { showNativeTabPanel() },
|
||
storage = webAppStorage,
|
||
onImportOfflinePackage = { id -> startImportOfflinePackage(id) },
|
||
onExportLog = { exportLog() },
|
||
)
|
||
root = FrameLayout(this)
|
||
setContentView(root)
|
||
desktopWebView = WebViewManager(this).attach(bridge, root)
|
||
desktopWebView.webViewClient = object : WebViewClient() {
|
||
// 主帧加载完成时打日志,便于定位黑屏(判断 index.html 是否成功加载)
|
||
// Log when the main frame finishes loading, to help diagnose a black screen
|
||
override fun onPageFinished(view: WebView?, url: String?) {
|
||
Log.d(TAG, "onPageFinished: url=$url")
|
||
// H5 就绪后重新拉取一次媒体,修复启动时推送早于页面渲染的时序问题
|
||
// Re-pull media once H5 is ready: fixes the startup timing where the
|
||
// media push happens before the page has rendered
|
||
bridge.refreshMedia()
|
||
}
|
||
|
||
// 主帧加载失败时展示错误页,避免白屏
|
||
// 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, desktopWebView)
|
||
Log.d(
|
||
TAG,
|
||
"setupWebView: attached WebView ${dm.widthPixels}x${dm.heightPixels}" +
|
||
" density=${dm.density} darkMode=$darkMode"
|
||
)
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
private val sidebarWidthPx by lazy {
|
||
(SIDEBAR_WIDTH_DP * resources.displayMetrics.density).toInt()
|
||
}
|
||
// 加载失败的 webapp id:Back 键时直接关闭而非 goBack 循环回退
|
||
// Failed webapp ids: on Back, close directly instead of looping goBack
|
||
private val failedWebViews = mutableSetOf<String>()
|
||
|
||
override fun openWebView(id: String, url: String, ua: String?, scale: Int?) {
|
||
if (contentWebViews.containsKey(id)) {
|
||
switchWebView(id)
|
||
return
|
||
}
|
||
val webView = WebViewManager(this@MainActivity).createContentWebView(ua, scale)
|
||
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")
|
||
failedWebViews.add(id)
|
||
view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null)
|
||
}
|
||
}
|
||
webView.webChromeClient = object : WebChromeClient() {
|
||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||
// 推送 webapp 页面加载进度给 H5(0-100)
|
||
// Push the webapp page load progress to H5 (0-100)
|
||
desktopWebView.evaluateJavascript(
|
||
"window.HearthEvents && window.HearthEvents.webappProgress('$id', $newProgress);",
|
||
null
|
||
)
|
||
}
|
||
}
|
||
val params = FrameLayout.LayoutParams(
|
||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||
ViewGroup.LayoutParams.MATCH_PARENT
|
||
)
|
||
params.topMargin = topBarHeightPx
|
||
params.leftMargin = sidebarWidthPx
|
||
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
|
||
failedWebViews.remove(id)
|
||
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 hideAll() {
|
||
contentWebViews.values.forEach { it.visibility = View.GONE }
|
||
currentVisibleId = null
|
||
Log.d(TAG, "hideAll: hidden ${contentWebViews.size} webviews")
|
||
}
|
||
|
||
override fun goBack(): Boolean {
|
||
val id = currentVisibleId ?: return false
|
||
// 加载失败的 webapp:Back 直接关闭(返回 false 让 onBackPressed 关闭标签),
|
||
// 避免 goBack 回退到失败的 file:// 页面反复加载
|
||
if (failedWebViews.contains(id)) return false
|
||
val webView = contentWebViews[id] ?: return false
|
||
return if (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>) {
|
||
// JsBridge 已把该调用 marshal 到主线程,这里可直接 evaluateJavascript
|
||
// JsBridge already marshaled this call to the main thread, so evaluateJavascript is safe
|
||
val json = gson.toJson(
|
||
tabs.map { mapOf("id" to it.id, "name" to it.name, "active" to it.active) }
|
||
)
|
||
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
|
||
}
|
||
}
|
||
|
||
// 弹出原生标签面板(PopupWindow,在内容 WebView 之上,不占用预留高度)
|
||
// Show the native tab panel (PopupWindow above the content WebView, no reserved height)
|
||
private fun showNativeTabPanel() {
|
||
val tabs = webAppContainer.tabs()
|
||
if (tabs.isEmpty()) return
|
||
|
||
val container = LinearLayout(this).apply {
|
||
orientation = LinearLayout.VERTICAL
|
||
setPadding(dp(8), dp(8), dp(8), dp(8))
|
||
background = GradientDrawable().apply {
|
||
setColor(0xE6202024.toInt())
|
||
cornerRadius = dp(16).toFloat()
|
||
}
|
||
}
|
||
|
||
val popup = PopupWindow(container, dp(220), ViewGroup.LayoutParams.WRAP_CONTENT, true)
|
||
popup.isOutsideTouchable = true
|
||
tabs.forEach { tab ->
|
||
val row = LinearLayout(this).apply {
|
||
orientation = LinearLayout.HORIZONTAL
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(12), dp(8), dp(12), dp(8))
|
||
if (tab.active) background = GradientDrawable().apply {
|
||
setColor(Color.parseColor("#ff6900"))
|
||
cornerRadius = dp(10).toFloat()
|
||
}
|
||
}
|
||
val name = TextView(this).apply {
|
||
text = tab.name
|
||
textSize = 14f
|
||
setTextColor(Color.WHITE)
|
||
isSingleLine = true
|
||
ellipsize = TextUtils.TruncateAt.END
|
||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||
}
|
||
val close = TextView(this).apply {
|
||
text = "✕"
|
||
textSize = 14f
|
||
setTextColor(Color.WHITE)
|
||
setPadding(dp(10), 0, 0, 0)
|
||
setOnClickListener {
|
||
bridge.closeWebApp(tab.id)
|
||
popup.dismiss()
|
||
}
|
||
}
|
||
row.addView(name)
|
||
row.addView(close)
|
||
row.setOnClickListener {
|
||
bridge.switchTab(tab.id)
|
||
popup.dismiss()
|
||
}
|
||
container.addView(row)
|
||
}
|
||
|
||
popup.showAtLocation(root, Gravity.TOP or Gravity.END, dp(16), dp(64))
|
||
}
|
||
|
||
// dp 转 px
|
||
// dp to px
|
||
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
|
||
|
||
// 导出日志到 Download 目录(读 logcat 本进程 + 过滤 Hearth 相关行)
|
||
// Export the log to the Download directory (read logcat, filter Hearth lines)
|
||
private fun exportLog(): String {
|
||
return try {
|
||
val process = Runtime.getRuntime().exec(arrayOf("logcat", "-d"))
|
||
val log = process.inputStream.bufferedReader().readText()
|
||
val filtered = log.lines()
|
||
.filter { it.contains("Hearth") }
|
||
.joinToString("\n")
|
||
val name = "hearth-log-" + System.currentTimeMillis() + ".txt"
|
||
val values = ContentValues().apply {
|
||
put(MediaStore.Downloads.DISPLAY_NAME, name)
|
||
put(MediaStore.Downloads.MIME_TYPE, "text/plain")
|
||
put(MediaStore.Downloads.RELATIVE_PATH, "Download/")
|
||
}
|
||
val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||
?: return "导出失败:无法创建文件"
|
||
contentResolver.openOutputStream(uri)?.use { out ->
|
||
out.write(filtered.toByteArray())
|
||
} ?: return "导出失败:无法写入"
|
||
Log.d(TAG, "exportLog: $name (${filtered.length} chars)")
|
||
"已导出:Download/$name"
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "exportLog failed: ${e.message}")
|
||
"导出失败:${e.message}"
|
||
}
|
||
}
|
||
|
||
// 触发 SAF 选择 zip 导入离线包
|
||
// Trigger SAF to pick a zip for offline-package import
|
||
private fun startImportOfflinePackage(id: String) {
|
||
pendingImportId = id
|
||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||
addCategory(Intent.CATEGORY_OPENABLE)
|
||
type = "application/zip"
|
||
}
|
||
startActivityForResult(intent, REQUEST_IMPORT_ZIP)
|
||
}
|
||
|
||
// SAF 结果:导入离线包,成功后通知 H5(下次点开用本地)
|
||
// SAF result: import the offline package, notify H5 on success (next open uses local)
|
||
@Deprecated("Deprecated in Java")
|
||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||
super.onActivityResult(requestCode, resultCode, data)
|
||
if (requestCode == REQUEST_IMPORT_ZIP && resultCode == Activity.RESULT_OK) {
|
||
val id = pendingImportId ?: return
|
||
val uri = data?.data ?: return
|
||
val ok = webAppStorage.importPackage(id, uri)
|
||
Log.d(TAG, "importPackage: id=$id ok=$ok")
|
||
desktopWebView.evaluateJavascript(
|
||
"window.HearthEvents && window.HearthEvents.webappImported('$id', $ok);",
|
||
null
|
||
)
|
||
}
|
||
pendingImportId = null
|
||
}
|
||
|
||
// 检测通知使用权是否已授予,未授予时打日志提示(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
|
||
private fun enterImmersive() {
|
||
window.insetsController?.let { ctrl ->
|
||
ctrl.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
|
||
ctrl.systemBarsBehavior =
|
||
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||
}
|
||
}
|
||
|
||
// Back 键:webAPP 打开时先回退,无历史则关闭激活标签;桌面态吞掉
|
||
// 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")
|
||
override fun onBackPressed() {
|
||
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
|
||
}
|
||
Log.d(TAG, "onBackPressed: desktop state, swallowed")
|
||
}
|
||
}
|