Files
Hearth/app/src/main/java/top/yeij/hearth/MainActivity.kt
T

564 lines
25 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 idSAF 回调用)
// 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 idBack 键时直接关闭而非 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 页面加载进度给 H50-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
// 加载失败的 webappBack 直接关闭(返回 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")
}
// 销毁全部内容 WebViewActivity 退出时释放资源)
// 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")
}
}