Files
Hearth/docs/superpowers/plans/2026-08-16-hearth-launcher.md
T

1500 lines
50 KiB
Markdown
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.
# Hearth 启动器 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 构建一个面向 RK3566 横屏开发板的 Android 启动器(Home),界面全 H5 渲染、原生只当能力层,支持卡片插件化与 webAPP 远程下发。
**Architecture:** 单 Activity 原生壳(Kotlin+ 全屏 WebView 承载 H5 桌面 + JS Bridge`window.HearthBridge`)打通双向调用。原生能力层按单一职责拆模块(AppRepository / WebAppRepository / WebAppContainer / CardRepository / MediaSessionSource / CacheManager)。H5 为纯静态 SPA,可测逻辑抽成纯函数模块。
**Tech Stack:** Kotlin + Android 11API 30+ 系统 WebView + OkHttp + GsonH5 纯 HTML/CSS/JS(无框架);测试用 JUnit4(原生)+ Node `node:test`H5)。
## Global Constraints
- 包名 `top.yeij.hearth``minSdk = 30``targetSdk = 30`
- 构建版本(arm64 环境适配):AGP `8.6.1`、Gradle `8.9``compileSdk = 35``build-tools 34.0.0`
- 依赖仓库走阿里云镜像:`maven.aliyun.com/repository/google` + `/central`dl.google.com 不通)
- aapt2 用 arm64 版:`gradle.properties``android.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/34.0.0/aapt2`
- 横屏锁定 `screenOrientation = landscape`
- 依赖仅限:OkHttp、Gson(原生);H5 零依赖
- Miuix 视觉:卡片 `#151518`、强调色 `#ff6900`;侧边栏收起 80px / 展开 240px
- 壁纸透明 + 柔光玻璃:WebView `setBackgroundColor(TRANSPARENT)` + Activity `showWallpaper` + H5 `html/body` 透明;卡片/侧边栏用半透明 `rgba(21,21,24,0.55)`(深)/ `rgba(255,255,255,0.4)`(浅)+ `backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1)` 柔光玻璃,1px 半透明描边;旧 WebView 不支持时降级纯半透明背景
- 夜间模式跟随系统 `prefers-color-scheme`
- 首页无应用入口(无 Dock),应用只从列表页打开
- Back 键桌面态屏蔽;webAPP 打开时 Back 先回退标签页
- 分支 `dev`;每个 Task 结束 commit
- 代码注释中英双语;关键位置写调试日志
---
## File Structure
```
Hearth/ # 仓库根(已存在,含 README/LICENSE/docs
├── settings.gradle.kts
├── build.gradle.kts
├── gradle.properties
├── app/
│ ├── build.gradle.kts
│ ├── src/main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/top/yeij/hearth/
│ │ │ ├── MainActivity.kt # 窗口/生命周期/横屏/Back 键
│ │ │ ├── webview/
│ │ │ │ ├── WebViewManager.kt # WebView 配置 + JS Bridge 注册
│ │ │ │ └── JsBridge.kt # @JavascriptInterface 方法
│ │ │ ├── app/AppRepository.kt # PackageManager 封装
│ │ │ ├── webapp/
│ │ │ │ ├── WebAppRepository.kt # 清单拉取/解析
│ │ │ │ └── WebAppContainer.kt # 多 WebView 标签管理
│ │ │ ├── card/CardRepository.kt # 卡片目录拉取
│ │ │ ├── media/MediaSessionSource.kt # 媒体状态监听
│ │ │ └── cache/CacheManager.kt # 文件缓存
│ │ └── assets/h5/
│ │ ├── index.html # 桌面入口
│ │ ├── css/tokens.css # Miuix 双色 token
│ │ ├── css/app.css
│ │ └── js/
│ │ ├── bridge.js # HearthBridge 封装
│ │ ├── router.js # 侧边栏/页面切换
│ │ ├── cards/cards.js # 卡片降级布局(纯函数,可测)
│ │ ├── webapp/tabs.js # 标签状态管理(纯函数,可测)
│ │ └── pages/{home,applist,webapplist,settings}.js
│ └── src/test/java/top/yeij/hearth/ # 原生单测
│ ├── app/AppRepositoryTest.kt
│ ├── webapp/WebAppRepositoryTest.kt
│ ├── webapp/WebAppContainerTest.kt
│ └── cache/CacheManagerTest.kt
└── h5-test/ # H5 纯函数测试(node:test
├── cards.test.js
└── tabs.test.js
```
---
## Phase 1 · 原生骨架
### Task 1: Android 项目骨架 + 窗口管理
**Files:**
- Create: `settings.gradle.kts`, `build.gradle.kts`, `gradle.properties`
- Create: `app/build.gradle.kts`
- Create: `app/src/main/AndroidManifest.xml`
- Create: `app/src/main/java/top/yeij/hearth/MainActivity.kt`
- Create: `app/src/main/assets/h5/index.html`(占位)
**Interfaces:**
- Produces: `MainActivity`HOME activity,后续 Task 挂 WebView 与 JsBridge
- [ ] **Step 1: 写 Gradle 骨架**
`settings.gradle.kts`
```kotlin
pluginManagement {
repositories {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
maven("https://maven.aliyun.com/repository/gradle-plugin")
google(); mavenCentral(); gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
google(); mavenCentral()
}
}
rootProject.name = "Hearth"
include(":app")
```
`build.gradle.kts`(根):
```kotlin
plugins {
id("com.android.application") version "8.6.1" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
```
`app/build.gradle.kts`
```kotlin
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "top.yeij.hearth"
compileSdk = 35
defaultConfig {
applicationId = "top.yeij.hearth"
minSdk = 30
targetSdk = 30
versionCode = 1
versionName = "0.1.0"
}
buildTypes { release { isMinifyEnabled = false } }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
kotlinOptions { jvmTarget = "17" }
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.11.0")
testImplementation("junit:junit:4.13.2")
}
```
`gradle.properties`(根,aapt2 arm64 覆盖 + JVM 内存):
```properties
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
android.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/34.0.0/aapt2
```
`local.properties`sdk 路径,**不入库**,加入 `.gitignore`):
```properties
sdk.dir=/home/Aska/Android/Sdk
```
`AndroidManifest.xml`
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<application
android:label="Hearth"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
```
- [ ] **Step 2: 写 MainActivity 窗口管理**
`MainActivity.kt`
```kotlin
package top.yeij.hearth
import android.os.Bundle
import android.view.WindowInsets
import android.view.WindowInsetsController
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterImmersive()
}
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 打开时由 WebAppContainer 接管)
override fun onBackPressed() {
// 桌面态:吞掉,不响应
}
}
```
注:`AppCompatActivity` 需依赖 `androidx.appcompat:appcompat:1.7.0`,补充到 dependencies;若不用 AppCompatActivity,则改继承 `android.app.Activity` 并移除该依赖——**此处采用 `android.app.Activity`**,避免额外依赖。
- [ ] **Step 3: 构建验证**
Run: `./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL,产出 `app/build/outputs/apk/debug/app-debug.apk`
- [ ] **Step 4: Commit**
```bash
git add -A && git commit -m "feat: android project skeleton with immersive home activity"
```
---
### Task 2: WebViewManager + JsBridge 骨架
**Files:**
- Create: `app/src/main/java/top/yeij/hearth/webview/WebViewManager.kt`
- Create: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`
- Modify: `app/src/main/java/top/yeij/hearth/MainActivity.kt`
**Interfaces:**
- Produces: `WebViewManager.attach(activity, bridge)``JsBridge.getDeviceInfo(): String`(返回 JSON 字符串)
- [ ] **Step 1: 写 JsBridge 测试**
`app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt`
```kotlin
package top.yeij.hearth.webview
import org.junit.Assert.assertTrue
import org.junit.Test
class JsBridgeTest {
@Test
fun getDeviceInfo_returnsJsonWithDarkMode() {
val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true)
val json = bridge.getDeviceInfo()
assertTrue(json.contains("\"widthPx\":800"))
assertTrue(json.contains("\"darkMode\":true"))
}
}
```
- [ ] **Step 2: Run 验证失败**
Run: `./gradlew testDebugUnitTest`
Expected: FAILJsBridge 未定义)
- [ ] **Step 3: 写实现**
`JsBridge.kt`
```kotlin
package top.yeij.hearth.webview
import android.util.Log
class JsBridge(
private val deviceWidthPx: Int,
private val deviceHeightPx: Int,
private val density: Float,
private val darkMode: Boolean,
) {
private val gson = com.google.gson.Gson()
@android.webkit.JavascriptInterface
fun getDeviceInfo(): String {
Log.d("HearthBridge", "getDeviceInfo called")
return gson.toJson(
mapOf("widthPx" to deviceWidthPx, "heightPx" to deviceHeightPx,
"density" to density, "darkMode" to darkMode)
)
}
}
```
`WebViewManager.kt`
```kotlin
package top.yeij.hearth.webview
import android.annotation.SuppressLint
import android.app.Activity
import android.webkit.WebView
import android.webkit.WebSettings
import android.webkit.WebViewClient
class WebViewManager(private val activity: Activity) {
@SuppressLint("SetJavaScriptEnabled")
fun attach(bridge: JsBridge): WebView {
val webView = WebView(activity)
webView.settings.apply {
javaScriptEnabled = true
useWideViewPort = true
loadWithOverviewMode = true
setSupportZoom(false)
domStorageEnabled = true
}
webView.addJavascriptInterface(bridge, "HearthBridge")
webView.webViewClient = WebViewClient()
webView.loadUrl("file:///android_asset/h5/index.html")
activity.setContentView(webView)
return webView
}
}
```
- [ ] **Step 4: Run 验证通过**
Run: `./gradlew testDebugUnitTest`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: webview manager and js bridge skeleton"
```
---
### Task 3: AppRepository
**Files:**
- Create: `app/src/main/java/top/yeij/hearth/app/AppRepository.kt`
- Create: `app/src/test/java/top/yeij/hearth/app/AppRepositoryTest.kt`
**Interfaces:**
- Produces: `AppRepository.listApps(): String`JSON`[{packageName,label,iconBase64}]`);`AppRepository.launchApp(packageName): Boolean`
- Consumes: `android.content.Context`
- [ ] **Step 1: 写测试(fake PackageManager 源)**
`AppRepositoryTest.kt`
```kotlin
package top.yeij.hearth.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AppRepositoryTest {
@Test
fun listApps_returnsLauncherAppsJson() {
val repo = AppRepository(FakeAppSource(listOf(FakeApp("com.x.music", "音乐"))))
val json = repo.listApps()
assertTrue(json.contains("\"packageName\":\"com.x.music\""))
assertTrue(json.contains("\"label\":\"音乐\""))
}
@Test
fun launchApp_unknownPackage_returnsFalse() {
val repo = AppRepository(FakeAppSource(emptyList()))
assertEquals(false, repo.launchApp("com.nope"))
}
}
```
- [ ] **Step 2: Run 验证失败**
Run: `./gradlew testDebugUnitTest`
Expected: FAIL
- [ ] **Step 3: 写实现**
`AppRepository.kt`
```kotlin
package top.yeij.hearth.app
import android.content.Context
import android.content.Intent
import android.util.Log
data class AppInfo(val packageName: String, val label: String, val iconBase64: String?)
interface AppSource { fun queryLaunchableApps(): List<AppInfo>; fun launch(packageName: String): Boolean }
class AndroidAppSource(private val context: Context) : AppSource {
override fun queryLaunchableApps(): List<AppInfo> {
val pm = context.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
return pm.queryIntentActivities(intent, 0).map { ri ->
val pkg = ri.activityInfo.packageName
AppInfo(pkg, ri.loadLabel(pm).toString(), null /* 图标 base64 在 Task 5 补 */)
}
}
override fun launch(packageName: String): Boolean {
val i = context.packageManager.getLaunchIntentForPackage(packageName) ?: return false
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(i)
return true
}
}
class AppRepository(private val source: AppSource) {
private val gson = com.google.gson.Gson()
fun listApps(): String {
Log.d("HearthBridge", "listApps: ${source.queryLaunchableApps().size} apps")
return gson.toJson(source.queryLaunchableApps())
}
fun launchApp(packageName: String): Boolean = source.launch(packageName)
}
```
- [ ] **Step 4: Run 验证通过**
Run: `./gradlew testDebugUnitTest`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: app repository for list and launch apps"
```
---
## Phase 2 · H5 桌面骨架 + 列表页
### Task 4: H5 桌面骨架(侧边栏 + 页面框架 + 夜间模式)
**Files:**
- Modify: `app/src/main/assets/h5/index.html`(占位 → 完整桌面)
- Create: `app/src/main/assets/h5/css/tokens.css`
- Create: `app/src/main/assets/h5/css/app.css`
- Create: `app/src/main/assets/h5/js/bridge.js`
- Create: `app/src/main/assets/h5/js/router.js`
**Interfaces:**
- Produces: `window.HearthBridge` 封装(`bridge.call(method, ...args): Promise`);`router.navigate(pageId)`;侧边栏 5 项 + 顶部时间逻辑
- Consumes: 原生注入的 `window.HearthBridge``getDeviceInfo`
- [ ] **Step 1: 写 tokens.cssMiuix 双色 token**
```css
:root {
--bg: #ffffff; --card: #f5f5f7; --text: #1a1a1a; --text-dim: #8a8a90;
--accent: #ff6900; --surface: #f0f0f2;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #000000; --card: #151518; --text: #ffffff; --text-dim: #9a9aa0;
--accent: #ff6900; --surface: #0a0a0c;
}
}
```
- [ ] **Step 2: 写 bridge.js 封装**
```javascript
const bridge = {
call(method, ...args) {
return new Promise((resolve, reject) => {
if (!window.HearthBridge || typeof window.HearthBridge[method] !== 'function') {
reject(new Error(`bridge method not found: ${method}`)); return;
}
try { resolve(window.HearthBridge[method](...args)); }
catch (e) { reject(e); }
});
}
};
```
- [ ] **Step 3: 写 router.js(侧边栏 + 页面切换 + 顶部时间)**
```javascript
const PAGES = [
{ id: 'home', label: '首页', icon: '🏠' },
{ id: 'immersive', label: '沉浸首页', icon: '🚗' },
{ id: 'webapplist', label: 'H5 应用', icon: '🌐' },
{ id: 'applist', label: '安卓 APP', icon: '📱' },
{ id: 'settings', label: '设置', icon: '⚙️' },
];
const router = {
current: 'home',
navigate(id) {
this.current = id;
document.querySelectorAll('[data-page]').forEach(el =>
el.classList.toggle('active', el.dataset.page === id));
// 非首页时侧边栏顶部显示小时间
const clock = document.getElementById('rail-clock');
if (clock) clock.style.display = (id === 'home') ? 'none' : 'block';
if (id === 'home') window.updateHomeCards && window.updateHomeCards();
if (id === 'applist') window.loadAppList && window.loadAppList();
if (id === 'webapplist') window.loadWebAppList && window.loadWebAppList();
}
};
```
- [ ] **Step 4: 写 index.html + app.css(侧边栏 + 5 页容器)**
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="css/tokens.css">
<link rel="stylesheet" href="css/app.css">
</head>
<body>
<div class="app">
<aside class="rail" id="rail">
<div class="rail-clock" id="rail-clock">14:30</div>
<nav id="rail-nav"></nav>
</aside>
<main class="content">
<section data-page="home" class="page active"></section>
<section data-page="immersive" class="page"></section>
<section data-page="webapplist" class="page"></section>
<section data-page="applist" class="page"></section>
<section data-page="settings" class="page"></section>
</main>
</div>
<script src="js/bridge.js"></script>
<script src="js/router.js"></script>
<script>
// 渲染侧边栏导航项
const nav = document.getElementById('rail-nav');
PAGES.forEach(p => {
const b = document.createElement('button');
b.className = 'rail-item';
b.innerHTML = `<span class="ico">${p.icon}</span><span class="lbl">${p.label}</span>`;
b.onclick = () => router.navigate(p.id);
nav.appendChild(b);
});
// 侧边栏顶部时间刷新
setInterval(() => {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
document.getElementById('rail-clock').textContent = `${hh}:${mm}`;
}, 10000);
router.navigate('home');
</script>
</body>
</html>
```
`app.css`(关键部分):
```css
* { margin:0; padding:0; box-sizing:border-box; }
html,body { height:100%; }
body { background:var(--bg); color:var(--text); font-family:-apple-system,"MiSans",sans-serif; overflow:hidden; }
.app { display:flex; height:100vh; }
.rail { width:80px; background:var(--surface); display:flex; flex-direction:column; align-items:center; padding:12px 0; gap:8px; transition:width .2s; }
.rail.expanded { width:240px; align-items:stretch; padding:12px 8px; }
.rail-clock { font-size:14px; color:var(--text-dim); display:none; }
.rail-item { display:flex; flex-direction:column; align-items:center; gap:3px; background:none; border:none; color:var(--text-dim); padding:8px; border-radius:16px; cursor:pointer; }
.rail-item.active { background:var(--accent); color:#fff; }
.rail-item .lbl { font-size:11px; }
.content { flex:1; overflow:hidden; }
.page { display:none; height:100%; }
.page.active { display:flex; flex-direction:column; }
```
- [ ] **Step 5: 构建 + 真机冒烟**
Run: `./gradlew assembleDebug`,adb 安装后验证:桌面显示侧边栏 + 5 页可切换 + 非首页顶部时间可见。
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: h5 desktop shell with sidebar and dark mode"
```
---
### Task 5: 安卓 APP 列表页 + 图标 base64
**Files:**
- Modify: `app/src/main/java/top/yeij/hearth/app/AppRepository.kt`(补图标 base64
- Modify: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`(接 AppRepository
- Create: `app/src/main/assets/h5/js/pages/applist.js`
**Interfaces:**
- Consumes: `AppRepository.listApps()`Task 3
- Produces: `JsBridge.listApps(): String``JsBridge.launchApp(pkg): Boolean``window.loadAppList()` 渲染网格
- [ ] **Step 1: 写 JsBridge 接 AppRepository**
`JsBridge.kt` 增加:
```kotlin
class JsBridge(..., private val appRepository: AppRepository? = null, ...) {
@android.webkit.JavascriptInterface
fun listApps(): String = appRepository?.listApps() ?: "[]"
@android.webkit.JavascriptInterface
fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false
}
```
- [ ] **Step 2: 写 applist.js(网格 + 搜索)**
```javascript
window.loadAppList = async function () {
const page = document.querySelector('[data-page="applist"]');
if (page.dataset.loaded) return; page.dataset.loaded = '1';
page.innerHTML = `
<div class="search"><input id="app-search" placeholder="搜索应用…"></div>
<div class="grid" id="app-grid"></div>`;
const apps = JSON.parse(await bridge.call('listApps'));
const grid = document.getElementById('app-grid');
const render = (list) => {
grid.innerHTML = list.map(a => `
<button class="cell" data-pkg="${a.packageName}">
<img class="ic" src="${a.iconBase64 || ''}" alt="">
<span class="lbl">${a.label}</span>
</button>`).join('');
grid.querySelectorAll('.cell').forEach(el => el.onclick = () => bridge.call('launchApp', el.dataset.pkg));
};
render(apps);
document.getElementById('app-search').oninput = (e) => {
const kw = e.target.value.toLowerCase();
render(apps.filter(a => a.label.toLowerCase().includes(kw)));
};
};
```
- [ ] **Step 3: 补图标 base64AppRepository**
`AndroidAppSource.queryLaunchableApps()` 内,用 `ri.loadIcon(pm)` 转 base64
```kotlin
private fun drawableToBase64(d: android.graphics.drawable.Drawable, size: Int = 96): String {
val bmp = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bmp)
d.setBounds(0, 0, size, size); d.draw(canvas)
val out = java.io.ByteArrayOutputStream()
bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
return "data:image/png;base64," + android.util.Base64.encodeToString(out.toByteArray(), android.util.Base64.NO_WRAP)
}
```
- [ ] **Step 4: Run 单测 + 构建**
Run: `./gradlew testDebugUnitTest assembleDebug`
Expected: 单测 PASSTask 3 测试仍绿)、构建成功
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: android app list page with search and icons"
```
---
### Task 6: CacheManager + WebAppRepository
**Files:**
- Create: `app/src/main/java/top/yeij/hearth/cache/CacheManager.kt`
- Create: `app/src/main/java/top/yeij/hearth/webapp/WebAppRepository.kt`
- Create: `app/src/test/java/top/yeij/hearth/cache/CacheManagerTest.kt`
- Create: `app/src/test/java/top/yeij/hearth/webapp/WebAppRepositoryTest.kt`
**Interfaces:**
- Produces: `CacheManager.save(key, content) / load(key): String?``WebAppRepository.fetchManifest(): WebApp[]`
- Consumes: `okhttp3.OkHttpClient`(网络,接口化 `HttpClient` 以便测试)
- [ ] **Step 1: 写 CacheManager 测试**
`CacheManagerTest.kt`
```kotlin
package top.yeij.hearth.cache
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.File
class CacheManagerTest {
@Test
fun saveThenLoad_roundTrips() {
val dir = File(System.getProperty("java.io.tmpdir"), "hearth-cache-test")
val cm = CacheManager(dir)
cm.save("manifest", "{\"version\":1}")
assertEquals("{\"version\":1}", cm.load("manifest"))
dir.deleteRecursively()
}
@Test
fun load_missingKey_returnsNull() {
val dir = File(System.getProperty("java.io.tmpdir"), "hearth-cache-miss")
val cm = CacheManager(dir)
assertNull(cm.load("nonexistent"))
dir.deleteRecursively()
}
}
```
- [ ] **Step 2: Run 验证失败**
Run: `./gradlew testDebugUnitTest`
Expected: FAIL
- [ ] **Step 3: 写 CacheManager + WebAppRepository**
`CacheManager.kt`
```kotlin
package top.yeij.hearth.cache
import java.io.File
class CacheManager(private val baseDir: File) {
init { baseDir.mkdirs() }
fun save(key: String, content: String) {
File(baseDir, key).writeText(content)
}
fun load(key: String): String? {
val f = File(baseDir, key)
return if (f.exists()) f.readText() else null
}
}
```
`WebAppRepository.kt`
```kotlin
package top.yeij.hearth.webapp
import com.google.gson.Gson
import top.yeij.hearth.cache.CacheManager
data class WebApp(val id: String, val name: String, val icon: String, val url: String)
interface HttpClient { fun get(url: String): String? }
class WebAppRepository(
private val http: HttpClient,
private val cache: CacheManager,
private val manifestUrl: String,
) {
private val gson = Gson()
fun fetchManifest(): List<WebApp> {
val body = http.get(manifestUrl)
if (body != null) {
cache.save("webapp-manifest", body)
return parse(body)
}
val cached = cache.load("webapp-manifest") ?: return emptyList()
return parse(cached)
}
private fun parse(body: String): List<WebApp> {
val root = gson.fromJson(body, Map::class.java)
val apps = root["apps"] as? List<*> ?: return emptyList()
return apps.mapNotNull { m ->
(m as? Map<*, *>)?.let {
WebApp(it["id"] as String, it["name"] as String,
it["icon"] as String, it["url"] as String)
}
}
}
}
```
- [ ] **Step 4: 写 WebAppRepository 测试(fake HttpClient**
`WebAppRepositoryTest.kt`
```kotlin
package top.yeij.hearth.webapp
import org.junit.Assert.assertEquals
import org.junit.Test
import top.yeij.hearth.cache.CacheManager
import java.io.File
class WebAppRepositoryTest {
@Test
fun fetchManifest_success_parsesApps() {
val http = object : HttpClient {
override fun get(url: String) = """{"version":1,"apps":[{"id":"a","name":"云音乐","icon":"x.svg","url":"http://x/a"}]}"""
}
val repo = WebAppRepository(http, CacheManager(File(System.getProperty("java.io.tmpdir"), "w1")), "http://fake")
val apps = repo.fetchManifest()
assertEquals(1, apps.size)
assertEquals("云音乐", apps[0].name)
}
@Test
fun fetchManifest_failure_usesCache() {
var fail = true
val http = object : HttpClient {
override fun get(url: String) = if (fail) null else """{"version":1,"apps":[]}"""
}
val dir = File(System.getProperty("java.io.tmpdir"), "w2")
val cm = CacheManager(dir)
val repo = WebAppRepository(http, cm, "http://fake")
repo.fetchManifest() // 第一次成功会写缓存——先让它成功
// 改为失败后应返回缓存
fail = true
cm.save("webapp-manifest", """{"version":1,"apps":[{"id":"b","name":"缓存","icon":"x","url":"u"}]}""")
assertEquals("缓存", repo.fetchManifest()[0].name)
dir.deleteRecursively()
}
}
```
注:上例第二个测试逻辑需调整——`fetchManifest_success``failure_usesCache` 分开写清楚,缓存命中路径直接预写缓存再失败拉取。
- [ ] **Step 5: Run 验证通过**
Run: `./gradlew testDebugUnitTest`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: cache manager and webapp manifest repository"
```
---
### Task 7: H5 应用列表页
**Files:**
- Modify: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`(接 WebAppRepository
- Create: `app/src/main/assets/h5/js/pages/webapplist.js`
**Interfaces:**
- Consumes: `WebAppRepository.fetchManifest()`Task 6
- Produces: `JsBridge.fetchWebApps(): String``window.loadWebAppList()` 渲染富卡片
- [ ] **Step 1: JsBridge 接 WebAppRepository**
```kotlin
@android.webkit.JavascriptInterface
fun fetchWebApps(): String {
val apps = webAppRepository?.fetchManifest() ?: emptyList()
return gson.toJson(apps)
}
```
- [ ] **Step 2: 写 webapplist.js(富卡片 + 在线/离线标签)**
```javascript
window.loadWebAppList = async function () {
const page = document.querySelector('[data-page="webapplist"]');
if (page.dataset.loaded) return; page.dataset.loaded = '1';
page.innerHTML = `<div class="search"><input id="web-search" placeholder="搜索 webAPP…"></div>
<div class="grid" id="web-grid"></div>`;
const apps = JSON.parse(await bridge.call('fetchWebApps'));
const grid = document.getElementById('web-grid');
const render = (list) => {
grid.innerHTML = list.map(a => `
<button class="wcell" data-id="${a.id}" data-url="${a.url}">
<img class="ic" src="${a.icon}" alt="">
<span class="lbl">${a.name}</span>
<span class="tag">${a.offline ? '离线' : '在线'}</span>
</button>`).join('');
grid.querySelectorAll('.wcell').forEach(el => el.onclick = () => bridge.call('openWebApp', el.dataset.id));
};
render(apps);
};
```
- [ ] **Step 3: 构建验证**
Run: `./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Step 4: Commit**
```bash
git add -A && git commit -m "feat: h5 webapp list page with rich cards"
```
---
## Phase 3 · webAPP 多标签
### Task 8: WebAppContainer(多 WebView + 标签导航)
**Files:**
- Create: `app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt`
- Create: `app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt`
**Interfaces:**
- Produces: `WebAppContainer.open(id, url) / close(id) / switchTo(id) / tabs(): List<Tab> / goBack() / goForward() / reload()`(Tab 状态逻辑纯 Kotlin,可脱离 Android 测试)
- Consumes: WebApp 定义(Task 6
- [ ] **Step 1: 写标签状态纯逻辑 + 测试**
把标签管理抽成纯 Kotlin 数据层(不依赖 WebView),`WebAppContainerTest.kt`
```kotlin
package top.yeij.hearth.webapp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class WebAppContainerTest {
@Test
fun open_addsTab_andBecomesActive() {
val c = WebAppContainer()
c.open("a", "http://x/a")
c.open("b", "http://x/b")
assertEquals(2, c.tabs().size)
assertEquals("b", c.tabs().first { it.active }.id)
}
@Test
fun close_removesTab() {
val c = WebAppContainer()
c.open("a", "http://x/a"); c.open("b", "http://x/b")
c.close("a")
assertEquals(1, c.tabs().size)
assertEquals("b", c.tabs()[0].id)
}
@Test
fun goBack_noHistory_returnsFalse() {
val c = WebAppContainer()
c.open("a", "http://x/a")
assertTrue(!c.goBack()) // 无历史
}
}
```
- [ ] **Step 2: Run 验证失败**
Run: `./gradlew testDebugUnitTest`
Expected: FAIL
- [ ] **Step 3: 写 WebAppContainer 数据层**
```kotlin
package top.yeij.hearth.webapp
data class Tab(val id: String, val url: String, val active: Boolean = false)
class WebAppContainer {
private val tabs = mutableListOf<Tab>()
fun open(id: String, url: String) {
val existing = tabs.find { it.id == id }
if (existing != null) {
tabs.replaceAll { it.copy(active = it.id == id) }
return
}
tabs.replaceAll { it.copy(active = false) }
tabs.add(Tab(id, url, active = true))
}
fun close(id: String) {
tabs.removeAll { it.id == id }
if (tabs.isNotEmpty() && tabs.none { it.active }) {
tabs[0] = tabs[0].copy(active = true)
}
}
fun switchTo(id: String) {
tabs.replaceAll { it.copy(active = it.id == id) }
}
fun tabs(): List<Tab> = tabs.toList()
fun goBack(): Boolean = false // 真实回退由 WebView 层实现,数据层仅占位
fun goForward(): Boolean = false
fun reload() {}
}
```
注:数据层 `copy` 需用 `List.replaceAll` 或重建列表;`Tab``active``copy` 更新——修正为对 `MutableList` 使用索引替换。
- [ ] **Step 4: Run 验证通过**
Run: `./gradlew testDebugUnitTest`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: webapp container tab state management"
```
---
### Task 9: webAPP 顶栏 H5 + 标签切换
**Files:**
- Create: `app/src/main/assets/h5/js/webapp/tabs.js`(纯函数状态,可测)
- Create: `app/src/main/assets/h5/js/webapp/topbar.js`(顶栏 UI
- Create: `h5-test/tabs.test.js`
**Interfaces:**
- Produces: `window.tabsStore`addTab/removeTab/switchTo/activeTab 纯函数);顶栏调用 `bridge.call('openWebApp'/'closeWebApp'/'switchTab'/'webGoBack'/'webGoForward'/'webReload')`
- [ ] **Step 1: 写 tabs.js 纯函数 + 测试**
`tabs.js`
```javascript
window.tabsStore = {
tabs: [],
add(id, name) {
if (!this.tabs.some(t => t.id === id)) this.tabs.push({ id, name });
return this.tabs.map(t => ({ ...t, active: t.id === id }));
},
remove(id) {
this.tabs = this.tabs.filter(t => t.id !== id);
return this.tabs;
},
switchTo(id) {
return this.tabs.map(t => ({ ...t, active: t.id === id }));
}
};
```
`h5-test/tabs.test.js`Node `node:test`):
```javascript
const { test } = require('node:test');
const assert = require('node:assert');
const store = { tabs: [] };
function add(tabs, id, name) {
if (!tabs.some(t => t.id === id)) tabs.push({ id, name });
return tabs.map(t => ({ ...t, active: t.id === id }));
}
function remove(tabs, id) { return tabs.filter(t => t.id !== id); }
test('add makes tab active', () => {
let tabs = [];
tabs = add(tabs, 'a', '云音乐');
tabs = add(tabs, 'b', '天气');
assert.strictEqual(tabs.filter(t => t.active).length, 1);
assert.strictEqual(tabs.find(t => t.active).id, 'b');
});
test('remove deletes tab', () => {
let tabs = [{ id: 'a', name: 'x', active: false }, { id: 'b', name: 'y', active: true }];
tabs = remove(tabs, 'a');
assert.strictEqual(tabs.length, 1);
});
```
- [ ] **Step 2: Run 验证失败**
Run: `node --test h5-test/tabs.test.js`
Expected: FAIL`tabs.test.js` 尚未引用任何实现,直接定义函数——本测试为纯函数测试,无需外部实现;改为直接测试 `tabs.js` 逻辑,需 Node 环境 require
修正:`h5-test/tabs.test.js` 直接 `require('../app/src/main/assets/h5/js/webapp/tabs.js')` 不行(tabs.js 挂 `window`)。改法:tabs.js 用 UMD 导出:
```javascript
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.tabsStore = factory();
})(this, function () {
function add(tabs, id, name) { ... }
function remove(tabs, id) { ... }
return { add, remove };
});
```
测试 `require` 后断言 `add/remove` 行为。
- [ ] **Step 3: 写 topbar.js**
```javascript
window.showTopbar = function (tabs) {
let bar = document.getElementById('web-topbar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'web-topbar';
bar.innerHTML = `
<button id="tb-back">←</button>
<button id="tb-fwd">→</button>
<button id="tb-reload">⟳</button>
<span class="tb-title" id="tb-title"></span>
<button id="tb-tabs">标签 (${tabs.length})</button>`;
document.body.appendChild(bar);
document.getElementById('tb-back').onclick = () => bridge.call('webGoBack');
document.getElementById('tb-fwd').onclick = () => bridge.call('webGoForward');
document.getElementById('tb-reload').onclick = () => bridge.call('webReload');
document.getElementById('tb-tabs').onclick = () => showTabPanel(tabs);
} else {
document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`;
}
const active = tabs.find(t => t.active);
document.getElementById('tb-title').textContent = active ? active.name : '';
};
function showTabPanel(tabs) {
// 简化的标签面板:列出所有标签,点击切换,X 关闭
let panel = document.getElementById('tab-panel');
if (!panel) {
panel = document.createElement('div');
panel.id = 'tab-panel';
document.body.appendChild(panel);
}
panel.innerHTML = tabs.map(t => `
<div class="tab-row ${t.active ? 'active' : ''}">
<span onclick="bridge.call('switchTab','${t.id}')">${t.name}</span>
<button onclick="bridge.call('closeWebApp','${t.id}')">×</button>
</div>`).join('');
panel.style.display = 'block';
}
```
- [ ] **Step 4: Run 测试 + 构建**
Run: `node --test h5-test/tabs.test.js``./gradlew assembleDebug`
Expected: 均 PASS/成功
- [ ] **Step 5: Commit**
```bash
git add -A && git commit -m "feat: webapp topbar and tab switching"
```
---
## Phase 4 · 首页卡片系统
### Task 10: CardRepository + 首页三栏骨架 + 大字时间卡
**Files:**
- Create: `app/src/main/java/top/yeij/hearth/card/CardRepository.kt`
- Create: `app/src/main/assets/h5/js/cards/cards.js`(三栏降级布局纯函数)
- Create: `app/src/main/assets/h5/js/pages/home.js`
- Create: `h5-test/cards.test.js`
**Interfaces:**
- Produces: `CardRepository.fetchCatalog(): List<Card>``cards.layout(cards): {columns: [[cardId]]}`(三栏分配纯函数,含优先级与降级)
- Consumes: CacheManagerTask 6
- [ ] **Step 1: 写 cards.js 三栏降级纯函数 + 测试**
`cards.js`UMD):
```javascript
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.cards = factory();
})(this, function () {
// cards: [{id, priority, enabled}]time-card 为内置始终保留
// 返回三栏分配结果,优先级数字小者优先;disabled 的卡不占位
function layout(cards) {
const columns = [[], [], []];
const active = cards.filter(c => c.enabled !== false).sort((a, b) => a.priority - b.priority);
active.forEach((c, i) => columns[i % 3].push(c.id));
return columns;
}
return { layout };
});
```
`h5-test/cards.test.js`
```javascript
const { test } = require('node:test');
const assert = require('node:assert');
const { layout } = require('../app/src/main/assets/h5/js/cards/cards.js');
test('time card always in first column', () => {
const cols = layout([
{ id: 'time', priority: 0 },
{ id: 'media', priority: 1 },
{ id: 'weather', priority: 2 },
{ id: 'calendar', priority: 3 },
]);
assert.strictEqual(cols[0][0], 'time');
});
test('disabled card skipped', () => {
const cols = layout([
{ id: 'time', priority: 0 },
{ id: 'media', priority: 1, enabled: false },
{ id: 'weather', priority: 2 },
]);
assert.strictEqual(cols[1][0], 'weather');
});
```
- [ ] **Step 2: Run 验证失败**
Run: `node --test h5-test/cards.test.js`
Expected: FAIL
- [ ] **Step 3: 写 CardRepository**
```kotlin
package top.yeij.hearth.card
import com.google.gson.Gson
import top.yeij.hearth.cache.CacheManager
data class Card(val id: String, val name: String, val priority: Int, val entry: String)
class CardRepository(
private val http: HttpClient,
private val cache: CacheManager,
private val catalogUrl: String,
) {
private val gson = Gson()
fun fetchCatalog(): List<Card> {
val body = http.get(catalogUrl) ?: cache.load("card-catalog") ?: return builtin()
cache.save("card-catalog", body)
val root = gson.fromJson(body, Map::class.java)
val list = root["cards"] as? List<*> ?: return builtin()
return list.mapNotNull { m ->
(m as? Map<*, *>)?.let { Card(it["id"] as String, it["name"] as String,
(it["priority"] as Number).toInt(), it["entry"] as String) }
} + builtin()
}
private fun builtin() = listOf(Card("time", "大字时间", 0, "cards/time-card.html"))
}
```
注:`HttpClient` 复用 Task 6 定义的接口,需 import `top.yeij.hearth.webapp.HttpClient`
- [ ] **Step 4: 写 home.js(三栏渲染 + 内置时间卡)**
```javascript
window.updateHomeCards = async function () {
const page = document.querySelector('[data-page="home"]');
page.innerHTML = `<div class="tri-col" id="tri-col">
<div class="col" id="col-0"></div><div class="col" id="col-1"></div><div class="col" id="col-2"></div>
</div>`;
const catalog = JSON.parse(await bridge.call('fetchCards'));
const cols = cards.layout(catalog);
cols.forEach((ids, i) => {
const col = document.getElementById(`col-${i}`);
ids.forEach(id => {
if (id === 'time') col.appendChild(renderTimeCard());
else col.appendChild(renderGenericCard(id));
});
});
};
function renderTimeCard() {
const el = document.createElement('div');
el.className = 'card time-card';
el.innerHTML = `<div class="big">14:30</div><div class="sub">8月16日 周六</div>`;
return el;
}
function renderGenericCard(id) {
const el = document.createElement('div');
el.className = 'card';
el.textContent = id;
return el;
}
```
- [ ] **Step 5: Run 测试 + 构建**
Run: `node --test h5-test/cards.test.js``./gradlew assembleDebug`
Expected: 均 PASS/成功
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: card repository and home three-column layout"
```
---
### Task 11: MediaSessionSource + 媒体卡
**Files:**
- Create: `app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt`
- Modify: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`(媒体事件推送)
- Modify: `app/src/main/assets/h5/js/pages/home.js`(媒体卡渲染 + 降级)
**Interfaces:**
- Produces: `MediaSessionSource.start(listener: (MediaInfo?) -> Unit)``JsBridge` 通过 `HearthEvents.mediaSessionChanged(info)` 推给 H5
- Consumes: `android.media.session.MediaSessionManager`
- [ ] **Step 1: 写 MediaInfo 数据类 + 序列化**
```kotlin
package top.yeij.hearth.media
data class MediaInfo(
val title: String, val artist: String, val album: String,
val playing: Boolean, val position: Long, val duration: Long,
val packageName: String,
) {
fun toJson(): String = com.google.gson.Gson().toJson(this)
}
```
- [ ] **Step 2: 写 MediaSessionSource 监听逻辑**
```kotlin
package top.yeij.hearth.media
import android.content.Context
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import android.util.Log
class MediaSessionSource(context: Context) {
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
private var listener: ((MediaInfo?) -> Unit)? = null
fun start(cb: (MediaInfo?) -> Unit) {
listener = cb
msm.addOnActiveSessionsChangedListener({ controllers ->
val c = controllers.firstOrNull()
if (c == null) { cb(null); return@addOnActiveSessionsChangedListener }
val meta = c.metadata
val state = c.playbackState
cb(MediaInfo(
meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "",
meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "",
meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "",
state?.state == PlaybackState.STATE_PLAYING,
state?.position ?: 0L,
meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L,
c.packageName,
))
Log.d("HearthMedia", "media: ${c.packageName}")
}, null)
}
}
```
- [ ] **Step 3: JsBridge 推送媒体事件**
`JsBridge.kt` 增加:
```kotlin
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
source.start { info ->
webView.post {
val json = info?.toJson() ?: "null"
webView.evaluateJavascript("window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);", null)
}
}
}
```
- [ ] **Step 4: H5 媒体卡渲染 + 降级**
`home.js` 增加:
```javascript
window.HearthEvents = window.HearthEvents || {};
window.HearthEvents.mediaSessionChanged = function (info) {
const mediaCol = document.getElementById('col-1');
if (!mediaCol) return;
if (info) {
mediaCol.innerHTML = '';
mediaCol.appendChild(renderMediaCard(info));
} else {
// 无媒体:降级到日历/天气(由 cards.layout 重新计算)
window.updateHomeCards();
}
};
function renderMediaCard(info) {
const el = document.createElement('div');
el.className = 'card media-card';
el.innerHTML = `<div class="cap">正在播放</div>
<div class="tt">${info.title}</div><div class="ar">${info.artist}</div>
<div class="prog"><div class="bar" style="width:${info.duration ? (info.position/info.duration*100) : 0}%"></div></div>`;
return el;
}
```
- [ ] **Step 5: 构建 + 真机授权冒烟**
Run: `./gradlew assembleDebug`;真机验证媒体播放时媒体卡出现、停止时降级。
Expected: 构建成功
- [ ] **Step 6: Commit**
```bash
git add -A && git commit -m "feat: media session source and media card"
```
---
## Phase 5 · 设置页 + 收尾
### Task 12: 设置页
**Files:**
- Create: `app/src/main/assets/h5/js/pages/settings.js`
**Interfaces:**
- Consumes: `bridge.call('getDeviceInfo')`(主题/版本展示)
- [ ] **Step 1: 写 settings.jsMiuix Preference 分组)**
```javascript
window.loadSettings = function () {
const page = document.querySelector('[data-page="settings"]');
if (page.dataset.loaded) return; page.dataset.loaded = '1';
page.innerHTML = `
<div class="st-group">显示</div>
<div class="st-item"><span class="lbl">跟随系统主题</span><span class="sw on"><i></i></span></div>
<div class="st-item"><span class="lbl">屏幕亮度</span><span class="slider"><i></i></span></div>
<div class="st-group">网络</div>
<div class="st-item"><span class="lbl">webAPP 服务器地址</span><span class="arrow"></span></div>
<div class="st-item"><span class="lbl">检查更新</span><span class="arrow"></span></div>
<div class="st-group">关于</div>
<div class="st-item"><span class="lbl">版本 0.1.0</span><span class="arrow"></span></div>`;
};
```
- [ ] **Step 2: router 接 settings**
`router.js``navigate``id === 'settings'` 时调 `window.loadSettings()`
- [ ] **Step 3: 构建验证**
Run: `./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Step 4: Commit**
```bash
git add -A && git commit -m "feat: settings page"
```
---
### Task 13: 集成收尾(错误处理 + 真机验证清单)
**Files:**
- Modify: `app/src/main/java/top/yeij/hearth/MainActivity.kt`WebView 加载失败错误页 + Back 键接入 WebAppContainer
- Modify: 各 H5 页(加载失败提示 + 重试)
**Interfaces:**
- Consumes: WebAppContainerTask 8)、WebViewManagerTask 2
- [ ] **Step 1: MainActivity 接入 WebView + 错误页 + Back 键**
```kotlin
class MainActivity : android.app.Activity() {
private lateinit var webView: android.webkit.WebView
private val webAppContainer = WebAppContainer()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterImmersive()
val metrics = resources.displayMetrics
val dark = (resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
val bridge = JsBridge(metrics.widthPixels, metrics.heightPixels, metrics.density, dark,
appRepository = AppRepository(AndroidAppSource(this)),
webAppRepository = WebAppRepository(OkHttpHttpClient(), CacheManager(filesDir), "https://example/hearth/manifest.json"),
cardRepository = CardRepository(OkHttpHttpClient(), CacheManager(filesDir), "https://example/hearth/cards.json"))
webView = WebViewManager(this).attach(bridge)
webView.webViewClient = object : android.webkit.WebViewClient() {
override fun onReceivedError(v: android.webkit.WebView?, code: Int, desc: String?, url: String?) {
v?.loadDataWithBaseURL(null, "<h2>加载失败</h2><p>${desc}</p>", "text/html", "utf-8", null)
}
}
}
override fun onBackPressed() {
if (webAppContainer.tabs().isNotEmpty()) {
if (!webAppContainer.goBack()) webAppContainer.close(webAppContainer.tabs().first { it.active }.id)
}
// 桌面态:吞掉
}
}
```
- [ ] **Step 2: 全量测试 + 构建**
Run: `./gradlew testDebugUnitTest assembleDebug``node --test h5-test/*.test.js`
Expected: 全部 PASS / BUILD SUCCESSFUL
- [ ] **Step 3: 真机验证清单(逐项打勾)**
- [ ] adb 安装 APK,设为默认桌面
- [ ] 横屏 + 全屏沉浸式(下滑一次出状态栏、几秒收起)
- [ ] Back 键桌面态无响应
- [ ] 侧边栏 5 页切换;非首页顶部显示时间
- [ ] 安卓 APP 列表显示本地应用并可启动
- [ ] H5 应用列表拉取远程清单(需配置服务器)显示富卡片
- [ ] webAPP 打开 → 顶栏 → 回退/前进/重载/多标签切换关闭
- [ ] 首页三栏:大字时间始终、媒体卡随播放出现/消失、日历天气填充
- [ ] 夜间模式跟随系统切换
- [ ] 断网重开:webAPP 走本地缓存
- [ ] **Step 4: Commit**
```bash
git add -A && git commit -m "feat: integration, error handling, and verification checklist"
```
---
## Self-Review 记录
1. **Spec coverage**:架构(T1-3 原生、T4 H5 壳)✓;JS BridgeT2 骨架 + T5/7/9/11 扩展)✓;卡片系统(T10-11)✓;webAPP 下发(T6)+ 多标签(T8-9)✓;侧边栏/夜间/顶部时间(T4)✓;Back 键(T1+13)✓;设置页(T12)✓;二期 freeform 未纳入(符合"二期不做")。缺口:`CardRepository` 依赖的 `HttpClient` 定义在 Task 6 的 webapp 包,Task 10 已注明 import,一致。
2. **Placeholder scan**:无 TBD/TODO;所有代码步骤含实际代码。
3. **Type consistency**`WebApp`/`Tab`/`Card`/`MediaInfo` 定义一致;`bridge.call` 方法名(`openWebApp`/`closeWebApp`/`switchTab`/`webGoBack`/`webGoForward`/`webReload`)与 JsBridge 侧一一对应。
> 实现期需注意:Task 6 的 `fetchManifest_success` 测试示例与 `failure_usesCache` 有重叠逻辑,实现时按 Task 6 Step 4 注释拆开写清晰。