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

50 KiB
Raw Blame History

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 Bridgewindow.HearthBridge)打通双向调用。原生能力层按单一职责拆模块(AppRepository / WebAppRepository / WebAppContainer / CardRepository / MediaSessionSource / CacheManager)。H5 为纯静态 SPA,可测逻辑抽成纯函数模块。

Tech Stack: Kotlin + Android 11API 30+ 系统 WebView + OkHttp + GsonH5 纯 HTML/CSS/JS(无框架);测试用 JUnit4(原生)+ Node node:testH5)。

Global Constraints

  • 包名 top.yeij.hearthminSdk = 30targetSdk = 30
  • 构建版本(arm64 环境适配):AGP 8.6.1、Gradle 8.9compileSdk = 35build-tools 34.0.0
  • 依赖仓库走阿里云镜像:maven.aliyun.com/repository/google + /centraldl.google.com 不通)
  • aapt2 用 arm64 版:gradle.propertiesandroid.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/34.0.0/aapt2
  • 横屏锁定 screenOrientation = landscape
  • 依赖仅限:OkHttp、Gson(原生);H5 零依赖
  • Miuix 视觉:深色背景 #000、卡片 #151518、强调色 #ff6900;侧边栏收起 80px / 展开 240px
  • 夜间模式跟随系统 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: MainActivityHOME activity,后续 Task 挂 WebView 与 JsBridge

  • Step 1: 写 Gradle 骨架

settings.gradle.kts

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(根):

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

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 内存):

org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
android.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/34.0.0/aapt2

local.propertiessdk 路径,不入库,加入 .gitignore):

sdk.dir=/home/Aska/Android/Sdk

AndroidManifest.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

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
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

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

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

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
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(): StringJSON[{packageName,label,iconBase64}]);AppRepository.launchApp(packageName): Boolean

  • Consumes: android.content.Context

  • Step 1: 写测试(fake PackageManager 源)

AppRepositoryTest.kt

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

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
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.HearthBridgegetDeviceInfo

  • Step 1: 写 tokens.cssMiuix 双色 token

: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 封装
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(侧边栏 + 页面切换 + 顶部时间)
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 页容器)
<!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(关键部分):

* { 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
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(): StringJsBridge.launchApp(pkg): Booleanwindow.loadAppList() 渲染网格

  • Step 1: 写 JsBridge 接 AppRepository

JsBridge.kt 增加:

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(网格 + 搜索)
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

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
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

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

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

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

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_successfailure_usesCache 分开写清楚,缓存命中路径直接预写缓存再失败拉取。

  • Step 5: Run 验证通过

Run: ./gradlew testDebugUnitTest Expected: PASS

  • Step 6: Commit
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(): Stringwindow.loadWebAppList() 渲染富卡片

  • Step 1: JsBridge 接 WebAppRepository

@android.webkit.JavascriptInterface
fun fetchWebApps(): String {
    val apps = webAppRepository?.fetchManifest() ?: emptyList()
    return gson.toJson(apps)
}
  • Step 2: 写 webapplist.js(富卡片 + 在线/离线标签)
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
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

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 数据层
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 或重建列表;Tabactivecopy 更新——修正为对 MutableList 使用索引替换。

  • Step 4: Run 验证通过

Run: ./gradlew testDebugUnitTest Expected: PASS

  • Step 5: Commit
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.tabsStoreaddTab/removeTab/switchTo/activeTab 纯函数);顶栏调用 bridge.call('openWebApp'/'closeWebApp'/'switchTab'/'webGoBack'/'webGoForward'/'webReload')

  • Step 1: 写 tabs.js 纯函数 + 测试

tabs.js

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.jsNode node:test):

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: FAILtabs.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 导出:

(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
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
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.jsUMD):

(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

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
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(三栏渲染 + 内置时间卡)
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
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 数据类 + 序列化

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 监听逻辑
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 增加:

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 增加:

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
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 分组)

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.jsnavigateid === 'settings' 时调 window.loadSettings()

  • Step 3: 构建验证

Run: ./gradlew assembleDebug Expected: BUILD SUCCESSFUL

  • Step 4: Commit
git add -A && git commit -m "feat: settings page"

Task 13: 集成收尾(错误处理 + 真机验证清单)

Files:

  • Modify: app/src/main/java/top/yeij/hearth/MainActivity.ktWebView 加载失败错误页 + Back 键接入 WebAppContainer
  • Modify: 各 H5 页(加载失败提示 + 重试)

Interfaces:

  • Consumes: WebAppContainerTask 8)、WebViewManagerTask 2

  • Step 1: MainActivity 接入 WebView + 错误页 + Back 键

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 assembleDebugnode --test h5-test/*.test.js Expected: 全部 PASS / BUILD SUCCESSFUL

  • Step 3: 真机验证清单(逐项打勾)

  • adb 安装 APK,设为默认桌面

  • 横屏 + 全屏沉浸式(下滑一次出状态栏、几秒收起)

  • Back 键桌面态无响应

  • 侧边栏 5 页切换;非首页顶部显示时间

  • 安卓 APP 列表显示本地应用并可启动

  • H5 应用列表拉取远程清单(需配置服务器)显示富卡片

  • webAPP 打开 → 顶栏 → 回退/前进/重载/多标签切换关闭

  • 首页三栏:大字时间始终、媒体卡随播放出现/消失、日历天气填充

  • 夜间模式跟随系统切换

  • 断网重开:webAPP 走本地缓存

  • Step 4: Commit

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 consistencyWebApp/Tab/Card/MediaInfo 定义一致;bridge.call 方法名(openWebApp/closeWebApp/switchTab/webGoBack/webGoForward/webReload)与 JsBridge 侧一一对应。

实现期需注意:Task 6 的 fetchManifest_success 测试示例与 failure_usesCache 有重叠逻辑,实现时按 Task 6 Step 4 注释拆开写清晰。