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() 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() 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) { // 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 """

加载失败

$safe

""".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") } }