Compare commits

...

23 Commits

Author SHA1 Message Date
AskaEth 381b9a57e7 feat: wire MainActivity with repositories, error page, and back handling 2026-08-16 17:03:07 +08:00
AskaEth abed4f4e15 feat: notification listener service and media source hardening 2026-08-16 17:02:11 +08:00
AskaEth 05a4009532 feat: settings page 2026-08-16 16:56:08 +08:00
AskaEth b157dac7ae feat: media session source and media card 2026-08-16 16:49:41 +08:00
AskaEth 695df96c24 fix: home time card fallback when no catalog 2026-08-16 16:47:02 +08:00
AskaEth fdaa8d0516 feat: card repository and home three-column layout 2026-08-16 16:43:30 +08:00
AskaEth 065625843d fix: topbar xss and stale closure 2026-08-16 16:37:15 +08:00
AskaEth 867ba3b098 feat: webapp topbar and tab switching 2026-08-16 16:34:39 +08:00
AskaEth f8639917f3 feat: webapp container tab state management 2026-08-16 16:30:45 +08:00
AskaEth ce55905e59 feat: h5 webapp list page with rich cards 2026-08-16 16:27:49 +08:00
AskaEth f3405e003f feat: cache manager and webapp manifest repository 2026-08-16 16:23:48 +08:00
AskaEth 0153ec38ae feat: android app list page with search and icons 2026-08-16 16:20:10 +08:00
AskaEth bd56d89cf3 feat: h5 desktop shell with sidebar and dark mode
- tokens.css/app.css: Miuix dual-color tokens + HyperOS 4 soft-glass rail
- bridge.js/router.js: Promise bridge wrapper + 5-page router with top clock
- index.html: sidebar + 5 page containers
- native wallpaper transparency: Theme.Hearth (showWallpaper + transparent window) + WebView transparent background
2026-08-16 16:15:58 +08:00
AskaEth dcb712731d fix: single-query listApps 2026-08-16 16:12:44 +08:00
AskaEth 77b52223c6 docs: add wallpaper transparency and hyperos-4 soft-glass style 2026-08-16 16:09:21 +08:00
AskaEth c4c19b610c feat: app repository for list and launch apps 2026-08-16 16:08:37 +08:00
AskaEth df0f9a2615 chore: disable AGP SDK auto-download (dl.google.com unreachable) 2026-08-16 16:01:36 +08:00
AskaEth 394a553da7 feat: webview manager and js bridge skeleton 2026-08-16 16:01:36 +08:00
AskaEth a764208c0e feat: android project skeleton with immersive home activity 2026-08-16 15:34:45 +08:00
AskaEth bb3699267f docs: adapt plan for arm64 build environment 2026-08-16 15:19:07 +08:00
AskaEth 4d1c4b294b docs: add implementation plan 2026-08-16 15:01:29 +08:00
AskaEth 425ffc42c9 docs: add webapp tab bar, back-key suppression, sidebar clock 2026-08-16 14:57:57 +08:00
AskaEth 5b5dc20f5a docs: add launcher design spec 2026-08-16 14:45:49 +08:00
46 changed files with 4099 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Superpowers brainstorm mockups / session state
.superpowers/
# Android 本地 SDK 路径
local.properties
# Gradle 构建产物
.gradle/
build/
app/build/
.idea/
*.iml
.kotlin/
+27
View File
@@ -0,0 +1,27 @@
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" }
// 本地单元测试中 android.* 桩方法返回默认值(避免 Log.d 抛 "not mocked"
// android.* stub methods return default values in local unit tests (avoid Log.d "not mocked")
testOptions { unitTests { isReturnDefaultValues = true } }
}
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")
}
+28
View File
@@ -0,0 +1,28 @@
<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"
android:theme="@style/Theme.Hearth">
<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>
<service
android:name=".media.HearthNotificationListenerService"
android:label="Hearth 媒体监听"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
</application>
</manifest>
+418
View File
@@ -0,0 +1,418 @@
/* Hearth 桌面样式:侧边栏 + 5 页框架 + 柔光玻璃容器 */
/* Hearth launcher styles: sidebar + 5-page frame + soft-glass containers */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
/* 壁纸透明:html/body 不设背景,露出原生壁纸 */
/* Wallpaper transparency: html/body keep transparent to reveal native wallpaper */
html, body { background: transparent; }
body {
color: var(--text);
font-family: -apple-system, "MiSans", "PingFang SC", sans-serif;
overflow: hidden;
}
.app { display: flex; height: 100vh; }
/* 侧边栏:柔光玻璃容器,非纯色背景 */
/* Sidebar: soft-glass container, not a solid color */
.rail {
width: 80px;
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
border-right: 1px solid var(--glass-border);
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 .ico { font-size: 20px; }
.rail-item .lbl { font-size: 11px; }
.rail-item.active { background: var(--accent); color: #fff; }
.content { flex: 1; overflow: hidden; }
.page { display: none; height: 100%; }
.page.active { display: flex; flex-direction: column; }
/* 首页三栏布局:三等分纵向列,卡片向下堆叠 */
/* Home three-column layout: three equal vertical columns, cards stack downward */
.tri-col {
flex: 1;
min-height: 0;
display: flex;
gap: 16px;
padding: 16px;
}
.col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
/* 卡片:柔光玻璃拟态(复用 Task 4 token,非纯色背景) */
/* Card: soft-glass morphism (reuse Task 4 tokens, not a solid color) */
.card {
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
border: 1px solid var(--glass-border);
border-radius: 14px;
padding: 20px;
color: var(--text);
}
/* 大字时间卡:主时间大字 + 日期副行 */
/* Big time card: large clock + date subtitle */
.time-card .big {
font-size: 56px;
font-weight: 300;
line-height: 1;
}
.time-card .sub {
margin-top: 8px;
font-size: 14px;
color: var(--text-dim);
}
/* 媒体卡:正在播放标签 + 标题/艺术家 + 进度条 */
/* Media card: playing caption + title/artist + progress bar */
.media-card .cap {
font-size: 11px;
color: var(--text-dim);
letter-spacing: 1px;
}
.media-card .tt {
margin-top: 6px;
font-size: 18px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-card .ar {
margin-top: 2px;
font-size: 13px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-card .prog {
margin-top: 12px;
height: 4px;
border-radius: 999px;
background: var(--glass-border);
overflow: hidden;
}
.media-card .bar {
height: 100%;
border-radius: 999px;
background: var(--accent);
}
/* 安卓 APP 列表页:搜索框 + 网格 */
/* Android app list page: search box + grid */
.search { padding: 16px; }
.search input {
width: 100%;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text);
font-size: 14px;
outline: none;
}
.grid {
flex: 1;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 12px;
padding: 0 16px 16px;
}
.cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 16px;
padding: 12px 8px;
cursor: pointer;
color: var(--text);
}
.cell .ic { width: 48px; height: 48px; border-radius: 12px; }
.cell .lbl { font-size: 12px; color: var(--text-dim); text-align: center; }
/* H5 应用列表页:富卡片 + 在线/离线标签 */
/* H5 web app list page: rich cards + online/offline badge */
.wcell {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 16px;
padding: 12px 8px;
cursor: pointer;
color: var(--text);
}
.wcell .ic { width: 48px; height: 48px; border-radius: 12px; }
.wcell .lbl { font-size: 12px; color: var(--text-dim); text-align: center; }
.wcell .tag {
position: absolute;
top: 6px;
right: 6px;
font-size: 10px;
padding: 2px 6px;
border-radius: 999px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-dim);
}
/* webAPP 顶栏:浮层玻璃条 + 标签面板 */
/* webapp topbar: floating glass bar + tab panel */
#web-topbar {
position: fixed;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
border: 1px solid var(--glass-border);
z-index: 100;
}
#web-topbar button {
width: 30px;
height: 30px;
border: none;
border-radius: 999px;
background: none;
color: var(--text);
font-size: 16px;
cursor: pointer;
}
#web-topbar button:active { background: var(--glass-border); }
#web-topbar #tb-tabs {
width: auto;
padding: 0 12px;
font-size: 13px;
color: var(--text-dim);
}
#web-topbar .tb-title {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: var(--text);
}
#tab-panel {
position: fixed;
top: 56px;
right: 16px;
display: none;
min-width: 180px;
max-width: 260px;
padding: 8px;
border-radius: 16px;
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2);
-webkit-backdrop-filter: blur(20px) saturate(1.2);
border: 1px solid var(--glass-border);
z-index: 100;
}
#tab-panel .tab-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
}
#tab-panel .tab-row.active { background: var(--accent); color: #fff; }
#tab-panel .tab-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
#tab-panel .tab-close {
border: none;
background: none;
color: inherit;
font-size: 16px;
cursor: pointer;
opacity: 0.7;
}
#tab-panel .tab-close:hover { opacity: 1; }
/* 设置页:Miuix Preference 分组 + 柔光玻璃条目 */
/* Settings page: Miuix Preference groups + soft-glass items */
.st-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
/* 分组标题:小号弱化文本 */
/* Group header: small dim text */
.st-group {
margin: 8px 4px 6px;
font-size: 13px;
color: var(--text-dim);
}
/* 设置项:玻璃拟态圆角行(复用 Task 4 token,非纯色背景) */
/* Setting item: soft-glass rounded row (reuse Task 4 tokens, not solid color) */
.st-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
margin-bottom: 8px;
border-radius: 14px;
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
border: 1px solid var(--glass-border);
color: var(--text);
}
.st-item .lbl { font-size: 15px; }
/* 右侧箭头:弱化色 */
/* Trailing arrow: dim color */
.st-item .arrow {
color: var(--text-dim);
font-size: 18px;
line-height: 1;
}
/* 开关:Miuix Switch(静态展示,交互留后续) */
/* Switch: Miuix style (static; interaction later) */
.sw {
position: relative;
flex: none;
width: 46px;
height: 28px;
border-radius: 999px;
background: rgba(127, 127, 127, 0.35);
transition: background .2s;
}
.sw.on { background: var(--accent); }
.sw i {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: left .2s;
}
.sw.on i { left: 21px; }
/* 滑块:Miuix Slider(静态展示:轨道 + 高亮填充 + 圆点) */
/* Slider: Miuix style (static: track + filled portion + thumb) */
.slider {
position: relative;
flex: none;
width: 140px;
height: 4px;
border-radius: 999px;
background: var(--glass-border);
}
.slider::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 60%;
border-radius: 999px;
background: var(--accent);
}
.slider i {
position: absolute;
top: 50%;
left: 60%;
transform: translate(-50%, -50%);
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
+19
View File
@@ -0,0 +1,19 @@
/* Hearth 设计 tokenHyperOS 4 柔光玻璃 + Miuix 双色 */
/* Hearth design tokens: HyperOS 4 soft-glass + Miuix dual-color scheme */
:root {
--glass-bg: rgba(255, 255, 255, 0.4); /* 浅色半透明白 / light translucent white */
--glass-border: rgba(0, 0, 0, 0.06);
--text: #1a1a1a;
--text-dim: #8a8a90;
--accent: #ff6900;
}
@media (prefers-color-scheme: dark) {
:root {
--glass-bg: rgba(21, 21, 24, 0.55); /* 深色半透明 / dark translucent */
--glass-border: rgba(255, 255, 255, 0.08);
--text: #ffffff;
--text-dim: #9a9aa0;
--accent: #ff6900;
}
}
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hearth</title>
<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/cards/cards.js"></script>
<script src="js/router.js"></script>
<script src="js/pages/applist.js"></script>
<script src="js/pages/webapplist.js"></script>
<script src="js/pages/home.js"></script>
<script src="js/pages/settings.js"></script>
<script src="js/webapp/tabs.js"></script>
<script src="js/webapp/topbar.js"></script>
<script>
// 渲染侧边栏导航项 / render sidebar nav items
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);
});
// 侧边栏顶部时间刷新 / refresh sidebar top clock
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>
+17
View File
@@ -0,0 +1,17 @@
// HearthBridge 原生桥接封装:统一 Promise 调用
// HearthBridge native bridge wrapper: unified Promise-based calls
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);
}
});
}
};
+20
View File
@@ -0,0 +1,20 @@
// 卡片三栏降级布局纯函数(UMD:Node require / 浏览器挂 window.cards
// Card three-column fallback layout pure function (UMD: Node require / browser window.cards)
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.cards = factory();
})(this, function () {
// cards: [{id, priority, enabled}]time 卡为内置始终保留
// 返回三栏分配结果,优先级数字小者优先;disabled 的卡不占位
// cards: [{id, priority, enabled}], time card is builtin and always kept
// Returns the three-column assignment; smaller priority comes first; disabled cards are skipped
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 };
});
@@ -0,0 +1,24 @@
// 安卓 APP 列表页:网格 + 搜索 + 图标 base64
// Android app list page: grid + search + icon base64
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)));
};
};
+118
View File
@@ -0,0 +1,118 @@
// 首页三栏卡片:拉取目录 -> cards.layout 分配三栏 -> 渲染(time 卡大字 + 其他占位)
// Home three-column cards: fetch catalog -> cards.layout assigns 3 columns -> render
// (big time card + generic placeholders for the rest)
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>`;
let catalog = [];
try {
catalog = JSON.parse(await bridge.call('fetchCards'));
} catch (e) {
// 桥接不可用(如未接线)时按空目录处理
// Treat bridge failure as an empty catalog
catalog = [];
}
if (!Array.isArray(catalog)) catalog = [];
// 兜底:无目录或缺失 time 卡时补内置大字时间卡,保证首页始终有时间卡
// Fallback: prepend builtin big time card when the catalog is empty or missing the time card
if (!catalog.some((c) => c.id === 'time')) {
catalog.push({ id: 'time', name: '大字时间', priority: 0 });
}
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));
});
});
startTimeCardTicker();
};
// 内置大字时间卡:当前时间 + 日期
// Builtin big time card: current time + date
function renderTimeCard() {
const el = document.createElement('div');
el.className = 'card time-card';
const now = new Date();
el.innerHTML = `<div class="big">${fmtTime(now)}</div><div class="sub">${fmtDate(now)}</div>`;
return el;
}
// 其他卡片占位:仅显示 id,后续 Task 接具体卡片渲染
// Other cards placeholder: show id only; concrete renderers land in later tasks
function renderGenericCard(id) {
const el = document.createElement('div');
el.className = 'card';
el.textContent = id;
return el;
}
function fmtTime(d) {
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
function fmtDate(d) {
const days = ['日', '一', '二', '三', '四', '五', '六'];
return `${d.getMonth() + 1}${d.getDate()}日 周${days[d.getDay()]}`;
}
// 每 10s 刷新一次大字时间卡
// Refresh the big time card every 10 seconds
let timeTicker = null;
function startTimeCardTicker() {
if (timeTicker) return;
timeTicker = setInterval(() => {
const card = document.querySelector('.time-card');
if (!card) return;
const now = new Date();
card.querySelector('.big').textContent = fmtTime(now);
card.querySelector('.sub').textContent = fmtDate(now);
}, 10000);
}
// 原生媒体会话事件:有媒体时在中间栏渲染媒体卡,无媒体时降级重新布局
// Native media session event: render media card in middle column when present,
// otherwise fall back to re-layout (calendar/weather fill the media card slot)
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 重新计算)
// No media: fall back to calendar/weather (recomputed by cards.layout)
window.updateHomeCards();
}
};
// 媒体卡:正在播放标签 + 标题/艺术家 + 进度条
// Media card: playing caption + title/artist + progress bar
function renderMediaCard(info) {
const el = document.createElement('div');
el.className = 'card media-card';
const pct = info.duration ? (info.position / info.duration) * 100 : 0;
// title/artist 来自任意应用元数据,转义后插入,防 XSS
// title/artist come from arbitrary app metadata; escape before insert (XSS)
el.innerHTML = `<div class="cap">正在播放</div>
<div class="tt">${escapeHtml(info.title)}</div><div class="ar">${escapeHtml(info.artist)}</div>
<div class="prog"><div class="bar" style="width:${pct}%"></div></div>`;
return el;
}
// 转义 HTML 特殊字符,避免媒体元数据注入标签
// Escape HTML special chars to prevent metadata injection
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
@@ -0,0 +1,16 @@
// 设置页:Miuix Preference 分组 + 玻璃条目(仅 UI 骨架,交互留后续)
// Settings page: Miuix Preference groups + glass items (UI skeleton only; interactions later)
window.loadSettings = function () {
const page = document.querySelector('[data-page="settings"]');
if (page.dataset.loaded) return; page.dataset.loaded = '1';
page.innerHTML = `<div class="st-wrap">
<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>
</div>`;
};
@@ -0,0 +1,25 @@
// H5 应用列表页:富卡片 + 在线/离线标签 + 搜索
// H5 web app list page: rich cards + online/offline badge + search
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);
document.getElementById('web-search').oninput = (e) => {
const kw = e.target.value.toLowerCase();
render(apps.filter(a => a.name.toLowerCase().includes(kw)));
};
};
+26
View File
@@ -0,0 +1,26 @@
// 桌面路由:侧边栏 5 页 + 页面切换 + 顶部时间
// Launcher router: 5 sidebar pages + page switching + top clock
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));
// 非首页时侧边栏顶部显示小时间
// Show the small clock at the sidebar top when not on home
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();
if (id === 'settings') window.loadSettings && window.loadSettings();
}
};
+27
View File
@@ -0,0 +1,27 @@
// webAPP 标签状态纯函数:不可变风格,Node 可 require、浏览器挂 window.tabsStore
// webapp tab state pure functions: immutable style, Node-requireable, browser exposes window.tabsStore
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.tabsStore = factory();
})(typeof self !== 'undefined' ? self : this, function () {
// 新增/激活标签:id 不存在才 push,返回激活 id 的新数组,不修改原数组
// Add/activate a tab: push only if id missing, return a new array with id active
function add(tabs, id, name) {
const base = tabs.some(t => t.id === id) ? tabs : tabs.concat({ id, name });
return base.map(t => ({ ...t, active: t.id === id }));
}
// 删除标签:过滤掉 id,返回新数组,不修改原数组
// Remove a tab: filter out id, return a new array
function remove(tabs, id) {
return tabs.filter(t => t.id !== id);
}
// 切换激活标签:返回激活 id 的新数组,不修改原数组
// Switch active tab: return a new array with id active
function switchTo(tabs, id) {
return tabs.map(t => ({ ...t, active: t.id === id }));
}
return { add, remove, switchTo };
});
@@ -0,0 +1,75 @@
// webAPP 顶栏:回退/前进/重载 + 标题 + 标签面板(切换/关闭)
// webapp topbar: back/forward/reload + title + tab panel (switch/close)
// 原生方法(webGoBack/webGoForward/webReload/switchTab/closeWebAppTask 13 才实现,
// 此处仅作为调用方,点击会静默 reject(符合预期)。
// Native methods (webGoBack/webGoForward/webReload/switchTab/closeWebApp) land in Task 13;
// this topbar is only a caller, clicks silently reject (expected).
// 展示/刷新顶栏:tabs 为标签数组(含 active 标记)
// Show/refresh the topbar: tabs is the tab array (with active flag)
window.showTopbar = function (tabs) {
let bar = document.getElementById('web-topbar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'web-topbar';
const back = document.createElement('button');
back.id = 'tb-back'; back.title = '回退'; back.textContent = '←';
const fwd = document.createElement('button');
fwd.id = 'tb-fwd'; fwd.title = '前进'; fwd.textContent = '→';
const reload = document.createElement('button');
reload.id = 'tb-reload'; reload.title = '重载'; reload.textContent = '⟳';
const title = document.createElement('span');
title.className = 'tb-title'; title.id = 'tb-title';
const tabsBtn = document.createElement('button');
tabsBtn.id = 'tb-tabs';
bar.append(back, fwd, reload, title, tabsBtn);
document.body.appendChild(bar);
back.onclick = () => bridge.call('webGoBack');
fwd.onclick = () => bridge.call('webGoForward');
reload.onclick = () => bridge.call('webReload');
}
// 每次刷新都重绑,避免捕获旧 tabs(陈旧闭包)
// Rebind on every refresh to avoid capturing stale tabs (stale closure)
document.getElementById('tb-tabs').onclick = () => showTabPanel(tabs);
document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`;
const active = tabs.find(t => t.active);
document.getElementById('tb-title').textContent = active ? active.name : '';
};
// 标签面板:列出所有标签,点击切换,× 关闭
// Tab panel: list all tabs, click to switch, × to close
// 用 createElement + textContent/dataset 渲染,避免 innerHTML 拼接用户数据(XSS
// Render with createElement + textContent/dataset to avoid innerHTML user data (XSS)
function showTabPanel(tabs) {
let panel = document.getElementById('tab-panel');
if (!panel) {
panel = document.createElement('div');
panel.id = 'tab-panel';
document.body.appendChild(panel);
}
panel.textContent = '';
tabs.forEach(t => {
const row = document.createElement('div');
row.className = 'tab-row' + (t.active ? ' active' : '');
row.dataset.id = t.id;
const name = document.createElement('span');
name.className = 'tab-name';
name.textContent = t.name;
const close = document.createElement('button');
close.className = 'tab-close';
close.title = '关闭';
close.textContent = '×';
name.onclick = () => { bridge.call('switchTab', t.id); closeTabPanel(); };
close.onclick = () => bridge.call('closeWebApp', t.id);
row.append(name, close);
panel.appendChild(row);
});
panel.style.display = 'block';
}
// 收起标签面板
// Collapse the tab panel
function closeTabPanel() {
const panel = document.getElementById('tab-panel');
if (panel) panel.style.display = 'none';
}
@@ -0,0 +1,157 @@
package top.yeij.hearth
import android.app.Activity
import android.content.res.Configuration
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.app.NotificationManagerCompat
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.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webview.JsBridge
import top.yeij.hearth.webview.WebViewManager
import java.io.File
// HOME 桌面 activity,接线各能力层:Repository、媒体监听、WebView 错误页与 Back 键
// HOME launcher activity, wiring all capability layers: repositories, media listener,
// WebView error page, and Back key handling
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"
}
private val webAppContainer = WebAppContainer()
private lateinit var mediaSource: MediaSessionSource
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate: enter immersive mode")
enterImmersive()
setupWebView()
checkNotificationAccess()
}
override fun onDestroy() {
mediaSource.stop()
super.onDestroy()
}
// 创建 JsBridge 并挂载 WebView 桌面层,接线所有 Repository 与媒体监听
// Create JsBridge and mount the WebView launcher 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()
val bridge = JsBridge(
deviceWidthPx = dm.widthPixels,
deviceHeightPx = dm.heightPixels,
density = dm.density,
darkMode = darkMode,
appRepository = AppRepository(AndroidAppSource(this)),
webAppRepository = WebAppRepository(http, cache, MANIFEST_URL),
cardRepository = CardRepository(http, cache, CATALOG_URL),
)
val webView = WebViewManager(this).attach(bridge)
webView.webViewClient = object : WebViewClient() {
// 主帧加载失败时展示错误页,避免白屏
// 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, webView)
Log.d(
TAG,
"setupWebView: attached WebView ${dm.widthPixels}x${dm.heightPixels}" +
" density=${dm.density} darkMode=$darkMode"
)
}
// 检测通知使用权是否已授予,未授予时打日志提示(UI 引导入口留后续)
// Check whether notification access is granted; log a hint when not
// (the settings UI entry is deferred to a later task)
private fun checkNotificationAccess() {
val granted = NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
Log.d(TAG, "checkNotificationAccess: notification listener enabled=$granted")
if (!granted) {
Log.d(
TAG,
"checkNotificationAccess: grant via Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"
)
}
}
// 构建加载失败错误页(转义系统错误描述,避免注入)
// Build the load-failure error page (escape the system error description)
private fun errorPage(desc: String): String {
val safe = TextUtils.htmlEncode(desc)
return """
<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body{font-family:sans-serif;display:flex;flex-direction:column;align-items:center;
justify-content:center;height:100vh;margin:0;background:transparent;color:#999}
h2{font-weight:400;color:#ccc}
p{font-size:14px;max-width:80vw;text-align:center;word-break:break-all}
</style></head><body><h2>加载失败</h2><p>$safe</p></body></html>
""".trimIndent()
}
// 进入沉浸式全屏:隐藏状态栏与导航栏,滑动可临时唤出
// Enter immersive fullscreen: hide status/navigation bars, swipe to reveal transiently
private fun enterImmersive() {
window.insetsController?.let { ctrl ->
ctrl.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
ctrl.systemBarsBehavior =
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
// Back 键:webAPP 打开时先回退,无历史则关闭激活标签;桌面态吞掉
// Back key: when a webAPP is open, go back first, then close the active tab if
// no history; consume the event on desktop state
@Suppress("DEPRECATION")
override fun onBackPressed() {
val tabs = webAppContainer.tabs()
if (tabs.isNotEmpty()) {
val active = tabs.firstOrNull { it.active }
if (active != null) {
// goBack() 当前为占位 false,实际回退逻辑留 WebView 导航接入
// goBack() is a placeholder returning false; real navigation lands later
if (!webAppContainer.goBack()) {
webAppContainer.close(active.id)
}
}
Log.d(TAG, "onBackPressed: webAPP back/close, remaining tabs=${webAppContainer.tabs().size}")
return
}
Log.d(TAG, "onBackPressed: desktop state, swallowed")
}
}
@@ -0,0 +1,59 @@
package top.yeij.hearth.app
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.util.Base64
import android.util.Log
import java.io.ByteArrayOutputStream
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(), drawableToBase64(ri.loadIcon(pm)))
}
}
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
}
// 图标 Drawable → 96px PNG base64 字符串(含 data:image/png;base64, 前缀)
// Convert app icon Drawable to a 96px PNG base64 string (with data:image/png;base64, prefix)
private fun drawableToBase64(d: Drawable, size: Int = 96): String {
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, size, size)
d.draw(canvas)
val out = ByteArrayOutputStream()
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
return "data:image/png;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
}
}
class AppRepository(private val source: AppSource) {
private val gson = com.google.gson.Gson()
fun listApps(): String {
val apps = source.queryLaunchableApps()
Log.d("HearthBridge", "listApps: ${apps.size} apps")
return gson.toJson(apps)
}
fun launchApp(packageName: String): Boolean = source.launch(packageName)
}
+18
View File
@@ -0,0 +1,18 @@
package top.yeij.hearth.cache
import java.io.File
// 文件缓存:以 key 为文件名,内容以 UTF-8 纯文本落盘
// File cache: uses key as filename, content stored as UTF-8 plain text
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
}
}
@@ -0,0 +1,64 @@
package top.yeij.hearth.card
import android.util.Log
import com.google.gson.Gson
import top.yeij.hearth.cache.CacheManager
import top.yeij.hearth.webapp.HttpClient
// 首页卡片条目:id 唯一标识,priority 数字小者排前,entry 为卡片渲染入口
// Home card entry: id is the unique key, smaller priority comes first, entry is the render entry point
data class Card(val id: String, val name: String, val priority: Int, val entry: String)
// 卡片目录仓库:拉取成功缓存并解析,失败回退缓存,无目录/无缓存返回内置 time 卡
// Card catalog repository: cache+parse on success, fall back to cache on failure,
// return builtin time card when there is no catalog or no cache
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)
if (body != null) {
cache.save(KEY, body)
Log.d(TAG, "fetchCatalog: fetched ${body.length} bytes from network")
return parse(body)
}
val cached = cache.load(KEY)
if (cached != null) {
Log.d(TAG, "fetchCatalog: network failed, using cache")
return parse(cached)
}
Log.d(TAG, "fetchCatalog: no network and no cache, return builtin time card")
return builtin()
}
// 解析目录 JSON{ "cards": [ {id,name,priority,entry} ] },字段缺失/非法时跳过该项
// Parse catalog JSON; skip malformed or missing-field entries
private fun parse(body: String): List<Card> {
val root = gson.fromJson(body, Map::class.java)
val list = root["cards"] as? List<*> ?: return builtin()
val parsed = list.mapNotNull { m ->
val map = m as? Map<*, *> ?: return@mapNotNull null
val id = map["id"] as? String ?: return@mapNotNull null
val name = map["name"] as? String ?: return@mapNotNull null
val priority = (map["priority"] as? Number)?.toInt() ?: return@mapNotNull null
val entry = map["entry"] as? String ?: return@mapNotNull null
Card(id, name, priority, entry)
}
// 内置 time 卡始终保留(追加在目录卡之后)
// builtin time card is always kept (appended after catalog cards)
return parsed + builtin()
}
// 内置卡:大字时间卡 priority 0,无目录时兜底
// Builtin card: big time card at priority 0, fallback when no catalog
private fun builtin(): List<Card> = listOf(Card("time", "大字时间", 0, "cards/time-card.html"))
companion object {
private const val TAG = "HearthCard"
private const val KEY = "card-catalog"
}
}
@@ -0,0 +1,13 @@
package top.yeij.hearth.media
import android.service.notification.NotificationListenerService
// 通知监听服务:空实现,仅作为「通知使用权」授权凭据,
// 让 MediaSessionManager.addOnActiveSessionsChangedListener(cb, listenerComponent)
// 能拿到活跃媒体会话(否则普通 APK 拿不到 MEDIA_CONTENT_CONTROL 系统权限)。
// Notification listener service: empty implementation, only serves as the
// "notification access" authorization credential so that
// MediaSessionManager.addOnActiveSessionsChangedListener(cb, listenerComponent)
// can receive active media sessions (a normal APK cannot hold the
// MEDIA_CONTENT_CONTROL system permission).
class HearthNotificationListenerService : NotificationListenerService()
@@ -0,0 +1,15 @@
package top.yeij.hearth.media
// 媒体会话快照:标题/艺术家/专辑 + 播放状态/进度 + 来源包名
// Media session snapshot: title/artist/album + playback state/progress + source package
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)
}
@@ -0,0 +1,77 @@
package top.yeij.hearth.media
import android.content.ComponentName
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
// 监听系统活跃媒体会话,取第一个 controller 的 metadata + playbackState 构造 MediaInfo 回调
// Watch active media sessions; build MediaInfo from the first controller's metadata + playbackState
class MediaSessionSource(context: Context) {
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
// 通知监听组件作为授权凭据传入 addOnActiveSessionsChangedListener
// 取代依赖 MEDIA_CONTENT_CONTROL 系统权限的 null 方案。
// The notification listener component is passed as the authorization credential,
// replacing the null-based approach that relied on the MEDIA_CONTENT_CONTROL permission.
private val listenerComponent = ComponentName(context, HearthNotificationListenerService::class.java)
private var callback: ((MediaInfo?) -> Unit)? = null
private var registered = false
// 具名监听器字段:回调通过 callback 字段转发,便于 start/stop 幂等管理
// Named listener field: forwards via callback, enabling idempotent start/stop
private val activeSessionsListener =
MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
val cb = callback ?: return@OnActiveSessionsChangedListener
val c = controllers?.firstOrNull()
if (c == null) {
Log.d(TAG, "no active media session")
cb(null)
return@OnActiveSessionsChangedListener
}
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(TAG, "media: ${c.packageName}")
}
// 注册监听:幂等——先移除旧监听再注册,避免重复 start 累积监听器
// Register the listener: idempotent — remove the old listener first, avoiding
// listener accumulation from repeated start() calls
fun start(cb: (MediaInfo?) -> Unit) {
stop()
callback = cb
msm.addOnActiveSessionsChangedListener(activeSessionsListener, listenerComponent)
registered = true
Log.d(TAG, "start: media session listener registered")
}
// 注销监听:释放回调引用,供 Activity onDestroy 调用
// Unregister the listener: release the callback, call from Activity onDestroy
fun stop() {
if (registered) {
msm.removeOnActiveSessionsChangedListener(activeSessionsListener)
registered = false
Log.d(TAG, "stop: media session listener removed")
}
callback = null
}
companion object {
private const val TAG = "HearthMedia"
}
}
@@ -0,0 +1,28 @@
package top.yeij.hearth.webapp
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
// 基于 OkHttp 的 HttpClient 实现:GET 返回响应体字符串,失败/非 2xx 返回 null
// OkHttp-backed HttpClient: GET returns the response body string, null on failure or non-2xx
class OkHttpHttpClient(
private val client: OkHttpClient = OkHttpClient(),
) : HttpClient {
override fun get(url: String): String? {
return try {
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { resp ->
if (resp.isSuccessful) resp.body?.string() else null
}
} catch (e: IOException) {
Log.d(TAG, "get failed: $url -> ${e.message}")
null
}
}
companion object {
private const val TAG = "HearthHttp"
}
}
@@ -0,0 +1,54 @@
package top.yeij.hearth.webapp
// 单个 WebView 标签:id 唯一标识,active 表示是否为当前激活标签
// Single WebView tab: id is the unique key, active marks the currently-focused tab
data class Tab(val id: String, val url: String, val active: Boolean = false)
// 多标签状态管理(纯 Kotlin 数据层,不依赖 WebView,可 JVM 单测)
// 真实 WebView 导航(goBack/goForward/reload)留待 Task 13 集成时接入
// Multi-tab state management (pure Kotlin data layer, WebView-free, JVM-testable)
// Real WebView navigation (goBack/goForward/reload) is wired up in Task 13
class WebAppContainer {
private val tabs = mutableListOf<Tab>()
// 打开标签:已存在则激活它(不重复添加),否则新增并激活
// Open a tab: if it already exists just activate it, otherwise add and activate
fun open(id: String, url: String) {
val existing = tabs.find { it.id == id }
if (existing != null) {
setActive(id)
return
}
for (i in tabs.indices) tabs[i] = tabs[i].copy(active = false)
tabs.add(Tab(id, url, active = true))
}
// 关闭标签:若关闭后没有激活标签则激活第一个
// Close a tab: if none remain active, activate the first one
fun close(id: String) {
tabs.removeAll { it.id == id }
if (tabs.isNotEmpty() && tabs.none { it.active }) {
tabs[0] = tabs[0].copy(active = true)
}
}
// 切换激活标签
// Switch the active tab
fun switchTo(id: String) {
setActive(id)
}
// 返回标签列表副本(外部无法直接改动内部状态)
// Return a defensive copy of the tab list
fun tabs(): List<Tab> = tabs.toList()
private fun setActive(id: String) {
for (i in tabs.indices) tabs[i] = tabs[i].copy(active = tabs[i].id == id)
}
// 真实回退由 WebView 层实现,数据层仅占位返回 false
// Real back navigation lives in the WebView layer; data layer is a stub
fun goBack(): Boolean = false
fun goForward(): Boolean = false
fun reload() {}
}
@@ -0,0 +1,62 @@
package top.yeij.hearth.webapp
import com.google.gson.Gson
import top.yeij.hearth.cache.CacheManager
// Web 应用清单条目
// offline 为离线包路径,null 表示纯在线应用
// Web app manifest entry
// offline is the offline package path, null means online-only
data class WebApp(
val id: String,
val name: String,
val icon: String,
val url: String,
val offline: String? = null,
)
// 网络抽象接口:Task 10 的 CardRepository 也会复用
// HTTP abstraction reused by Task 10 CardRepository
interface HttpClient {
fun get(url: String): String?
}
// 清单拉取仓库:拉取成功缓存 body 并解析;失败回退缓存;无缓存返回空列表
// Manifest repository: on success cache + parse; on failure fall back to cache; no cache -> empty list
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(KEY, body)
return parse(body)
}
val cached = cache.load(KEY) ?: 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 ->
val map = m as? Map<*, *> ?: return@mapNotNull null
val id = map["id"] as? String ?: return@mapNotNull null
val name = map["name"] as? String ?: return@mapNotNull null
val icon = map["icon"] as? String ?: return@mapNotNull null
val url = map["url"] as? String ?: return@mapNotNull null
// offline 是对象时取其 package 字段,否则 null(纯在线)
// offline is an object -> take its package field, otherwise null (online-only)
val offline = (map["offline"] as? Map<*, *>)?.get("package") as? String
WebApp(id, name, icon, url, offline)
}
}
companion object {
private const val KEY = "webapp-manifest"
}
}
@@ -0,0 +1,73 @@
package top.yeij.hearth.webview
import android.util.Log
import top.yeij.hearth.app.AppRepository
import top.yeij.hearth.card.Card
import top.yeij.hearth.card.CardRepository
import top.yeij.hearth.media.MediaSessionSource
import top.yeij.hearth.webapp.WebApp
import top.yeij.hearth.webapp.WebAppRepository
class JsBridge(
private val deviceWidthPx: Int,
private val deviceHeightPx: Int,
private val density: Float,
private val darkMode: Boolean,
private val appRepository: AppRepository? = null,
private val webAppRepository: WebAppRepository? = null,
private val cardRepository: CardRepository? = null,
) {
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
)
)
}
// 返回已安装应用列表 JSON(无仓库时返回空数组)
// Return installed app list JSON (empty array when no repository wired)
@android.webkit.JavascriptInterface
fun listApps(): String = appRepository?.listApps() ?: "[]"
// 启动指定包名应用,成功返回 true(无仓库时返回 false)
// Launch an app by package name, true on success (false when no repository wired)
@android.webkit.JavascriptInterface
fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false
// 返回 H5 应用清单 JSON(无仓库时返回空数组)
// Return the H5 web app manifest JSON (empty array when no repository wired)
@android.webkit.JavascriptInterface
fun fetchWebApps(): String = gson.toJson(webAppRepository?.fetchManifest() ?: emptyList<WebApp>())
// 返回首页卡片目录 JSON(无仓库时返回空数组;有仓库时由 fetchCatalog 保证内置 time 卡)
// Return the home card catalog JSON (empty array when no repository wired;
// fetchCatalog guarantees the builtin time card when wired)
@android.webkit.JavascriptInterface
fun fetchCards(): String {
val cards = cardRepository?.fetchCatalog() ?: emptyList<Card>()
Log.d("HearthBridge", "fetchCards: ${cards.size} cards")
return gson.toJson(cards)
}
// 注册媒体会话监听,回调推送给 H5(info 为 null 时推 "null"
// Register media session listener; push callbacks to H5 (push "null" when info is null)
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
)
}
}
}
}
@@ -0,0 +1,29 @@
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)
// 透明背景,露出原生壁纸
// Transparent background to reveal native wallpaper
webView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
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
}
}
+8
View File
@@ -0,0 +1,8 @@
<resources>
<!-- Hearth 桌面主题:显示壁纸 + 透明窗口背景,让 WebView 露出壁纸 -->
<!-- Hearth launcher theme: show wallpaper + transparent window so WebView reveals wallpaper -->
<style name="Theme.Hearth" parent="android:Theme.Material.NoActionBar">
<item name="android:windowShowWallpaper">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style>
</resources>
@@ -0,0 +1,21 @@
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"))
}
}
@@ -0,0 +1,13 @@
package top.yeij.hearth.app
// 测试用假源:返回固定应用列表,避免依赖 Android Context/PackageManager
// Test fake source: returns a fixed app list, avoiding dependency on Android Context/PackageManager
class FakeAppSource(private val apps: List<AppInfo>) : AppSource {
override fun queryLaunchableApps(): List<AppInfo> = apps
override fun launch(packageName: String): Boolean = apps.any { it.packageName == packageName }
}
// 测试用 AppInfo 构造辅助函数
// Test helper to construct an AppInfo
fun FakeApp(packageName: String, label: String): AppInfo = AppInfo(packageName, label, null)
@@ -0,0 +1,25 @@
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()
}
}
@@ -0,0 +1,53 @@
package top.yeij.hearth.card
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.hearth.cache.CacheManager
import top.yeij.hearth.webapp.HttpClient
import java.io.File
class CardRepositoryTest {
@Test
fun fetchCatalog_success_parsesCards_andKeepsBuiltinTime() {
val http = object : HttpClient {
override fun get(url: String) =
"""{"version":1,"cards":[{"id":"weather","name":"天气","priority":2,"entry":"cards/weather.html"}]}"""
}
val repo = CardRepository(
http,
CacheManager(File(System.getProperty("java.io.tmpdir"), "c1")),
"http://fake",
)
val cards = repo.fetchCatalog()
assertEquals(setOf("weather", "time"), cards.map { it.id }.toSet())
assertEquals("天气", cards.first { it.id == "weather" }.name)
}
@Test
fun fetchCatalog_failure_usesCache() {
val dir = File(System.getProperty("java.io.tmpdir"), "c2")
val cm = CacheManager(dir)
cm.save(
"card-catalog",
"""{"cards":[{"id":"weather","name":"天气","priority":2,"entry":"cards/weather.html"}]}""",
)
val http = object : HttpClient { override fun get(url: String): String? = null }
val repo = CardRepository(http, cm, "http://fake")
val cards = repo.fetchCatalog()
assertTrue(cards.any { it.id == "weather" })
assertTrue(cards.any { it.id == "time" })
dir.deleteRecursively()
}
@Test
fun fetchCatalog_failure_withoutCache_returnsBuiltinTime() {
val dir = File(System.getProperty("java.io.tmpdir"), "c3")
val http = object : HttpClient { override fun get(url: String): String? = null }
val repo = CardRepository(http, CacheManager(dir), "http://fake")
val cards = repo.fetchCatalog()
assertEquals(1, cards.size)
assertEquals("time", cards[0].id)
dir.deleteRecursively()
}
}
@@ -0,0 +1,61 @@
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()) // 无历史
}
@Test
fun open_existingTab_activatesWithoutDuplicating() {
val c = WebAppContainer()
c.open("a", "http://x/a")
c.open("b", "http://x/b")
c.open("a", "http://x/a")
assertEquals(2, c.tabs().size)
assertEquals("a", c.tabs().first { it.active }.id)
}
@Test
fun switchTo_activatesSpecifiedTab() {
val c = WebAppContainer()
c.open("a", "http://x/a")
c.open("b", "http://x/b")
c.switchTo("a")
assertEquals("a", c.tabs().first { it.active }.id)
assertEquals(1, c.tabs().count { it.active })
}
@Test
fun close_activeTab_activatesFirstRemaining() {
val c = WebAppContainer()
c.open("a", "http://x/a")
c.open("b", "http://x/b")
c.close("b")
assertEquals("a", c.tabs().first { it.active }.id)
}
}
@@ -0,0 +1,44 @@
package top.yeij.hearth.webapp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
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() {
val dir = File(System.getProperty("java.io.tmpdir"), "w2")
val cm = CacheManager(dir)
// 预写缓存(模拟之前拉取成功留下的缓存)
// Pre-seed the cache (simulate a previously successful fetch)
cm.save("webapp-manifest", """{"version":1,"apps":[{"id":"b","name":"缓存","icon":"x","url":"u"}]}""")
// 网络失败
// Network failure
val http = object : HttpClient { override fun get(url: String): String? = null }
val repo = WebAppRepository(http, cm, "http://fake")
assertEquals("缓存", repo.fetchManifest()[0].name)
dir.deleteRecursively()
}
@Test
fun fetchManifest_failure_withoutCache_returnsEmpty() {
val dir = File(System.getProperty("java.io.tmpdir"), "w3")
val http = object : HttpClient { override fun get(url: String): String? = null }
val repo = WebAppRepository(http, CacheManager(dir), "http://fake")
assertTrue(repo.fetchManifest().isEmpty())
dir.deleteRecursively()
}
}
@@ -0,0 +1,41 @@
package top.yeij.hearth.webview
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.hearth.card.CardRepository
import top.yeij.hearth.cache.CacheManager
import top.yeij.hearth.webapp.HttpClient
import java.io.File
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"))
}
@Test
fun fetchCards_withoutRepository_returnsEmptyArray() {
val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true)
assertTrue(bridge.fetchCards() == "[]")
}
@Test
fun fetchCards_withRepository_returnsBuiltinTimeCard() {
val dir = File(System.getProperty("java.io.tmpdir"), "jb-cards")
val repo = CardRepository(
object : HttpClient { override fun get(url: String): String? = null },
CacheManager(dir),
"http://fake",
)
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
cardRepository = repo,
)
val json = bridge.fetchCards()
assertTrue(json.contains("\"id\":\"time\""))
dir.deleteRecursively()
}
}
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.6.1" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,323 @@
# Hearth 启动器 · 设计文档
> 版本:v1.0
> 日期:2026-08-16
> 包名:`top.yeij.hearth`
## 1. 概述
Hearth 是一个面向瑞芯微 RK3566 横屏开发板(立创·泰山派)的 Android 启动器(Home)。
核心特色:**界面全 H5 渲染,原生只当「能力层」**,通过 JS Bridge 向 H5 暴露系统能力,
使得桌面 UI、卡片、webAPP 均可远程下发、动态扩展,无需重装 APK。
### 目标平台
| 项 | 值 |
|---|---|
| 硬件 | 立创·泰山派 RK3566 开发板 |
| 系统 | Android 11API 30 |
| 屏幕 | 横屏 800×480 物理像素,高 DPI(≈300+,自适应系统 density |
| 兼容策略 | `minSdk = 30`,仅针对该板,不做低版本兼容 |
## 2. 需求汇总(已确认)
1. **全 H5 渲染架构**:桌面 = 全屏 WebView 承载 H5,原生只提供系统能力(查应用/启动应用/媒体状态/下载缓存等)。
2. **全局左侧边栏**Miuix NavigationRail 形态,可切换(收起 80px 纯图标 / 展开 240px 图标+文字),
用于切换 5 个页面:首页 / 沉浸首页 / H5 应用列表 / 安卓 APP 列表 / 设置。
3. **首页**:左中右三栏卡片流。大字时间卡始终保留;媒体卡动态(有活动媒体才显示);
日历/天气/小工具填充富余空间。**首页无应用入口(不做 Dock),应用只从列表页打开**。
4. **卡片插件化**:每个卡片 = 独立 HTML + 一份 manifest(声明所需数据源/权限),可后期扩展、可远程下发。
5. **沉浸首页(二期)**:左 2/3 freeform 窗口跑第三方 App + 右侧卡片列(时间/媒体/天气)。
6. **列表页**:安卓 APP 列表 = 图标网格 + 搜索;H5 应用列表 = webAPP 富卡片(缩略图 + 在线/离线标签)。
7. **webAPP 下发**:JSON 清单 + 可选离线包,远程下发 + 本地缓存。
8. **UI 风格**Miuix / HyperOS 视觉(纯视觉,H5 用 CSS 还原,非 Compose 库)。
9. **状态栏**:系统默认沉浸式(全屏隐藏,顶部下滑一次展开,几秒自动收起)。
10. **夜间模式**:跟随系统(`prefers-color-scheme`)。
11. **横屏锁定**:固定横屏。
## 3. 架构
```
┌──────────────────────── Hearth APK ────────────────────────┐
│ MainActivity(唯一 Activity
│ ├─ 窗口管理:全屏沉浸式 / 横屏锁定 / 夜间模式 │
│ └─ WebView(全屏,加载 assets/index.html
│ │ window.HearthBridge (JS Bridge) │
│ ▼ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 能力层(原生 Kotlin) │ │
│ │ ├─ AppRepository 查应用列表/图标/启动 │ │
│ │ ├─ WebAppRepository 拉 webAPP 清单/离线包 │ │
│ │ ├─ WebAppContainer 多 WebView 管理(标签/导航) │ │
│ │ ├─ CardRepository 卡片清单拉取/缓存 │ │
│ │ ├─ MediaSessionSource 媒体状态监听 │ │
│ │ ├─ CacheManager 文件缓存(清单/离线包/图标) │ │
│ │ └─ JsBridge 暴露给 H5 的方法 │ │
│ └────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
│ 网络(webAPP / 卡片 服务器)
┌─ 服务器 ──────────────┐
│ manifest.json │ ← webAPP 清单
│ offlines/*.zip │ ← 可选离线包
│ cards/catalog.json │ ← 卡片目录
│ cards/<id>/card.html │ ← 卡片资源
└───────────────────────┘
```
### 模块职责(单一职责)
| 模块 | 职责 | 依赖 |
|---|---|---|
| `MainActivity` | 窗口、生命周期、横屏锁、沉浸式 | WebViewManager |
| `WebViewManager` | WebView 配置(内核设置、viewport、注册 JS Bridge | JsBridge |
| `JsBridge` | 所有 `@JavascriptInterface` 方法,H5 唯一入口 | 各 Repository/Source |
| `AppRepository` | PackageManager 封装:应用列表、base64 图标、启动应用 | 无 |
| `WebAppRepository` | 拉取 webAPP 清单、下载离线包、解析 | CacheManager |
| `WebAppContainer` | 多 WebView 管理:标签页打开/切换/关闭、前进后退、重载 | 无 |
| `CardRepository` | 拉取卡片目录、下载卡片资源 | CacheManager |
| `MediaSessionSource` | 监听活跃媒体会话,推送媒体元数据 | 无 |
| `CacheManager` | 文件读写缓存(清单/离线包/卡片/图标) | 无 |
### 数据流(核心路径)
1. **启动**MainActivity 全屏 → WebView 加载 `assets/index.html`(H5 桌面,随 APK 打包,保证离线可用)。
2. **渲染 APP 列表**H5 调 `listApps()` → 原生查 PackageManager → 回调 JSON(含 base64 图标)→ H5 渲染网格。
3. **渲染 webAPP 列表**H5 调 `fetchWebApps()` → 原生拉服务器清单 → 缓存 → 回调 → H5 渲染富卡片。
4. **启动 APP**:点击 → H5 调 `launchApp(pkg)` → 原生 `startActivity`
5. **打开 webAPP**:点击 → H5 调 `openWebApp(id)` → 原生新建内容 WebView(标签页)加载 URL,桌面 H5 顶部渲染浏览器式顶栏;回退/前进/重载/标签切换均经 JS Bridge 控制。
6. **首页卡片**H5 调 `fetchCards()` → 原生拉卡片目录 → 下载卡片 HTML + 数据 → H5 渲染三栏卡片流。
7. **媒体卡**:原生 MediaSessionSource 监听媒体变化 → 事件推给 H5 → H5 显示/隐藏媒体卡。
## 4. JS Bridge 协议
H5 通过 `window.HearthBridge` 访问原生能力。所有方法异步回调(`callbackId` 模式或 Promise 封装)。
### 4.1 H5 → 原生(方法调用)
| 方法 | 参数 | 返回 | 说明 |
|---|---|---|---|
| `listApps()` | 无 | AppInfo[] | 已装应用列表(包名/名称/base64 图标) |
| `launchApp(pkg)` | 包名 | void | 启动第三方应用 |
| `fetchWebApps()` | 无 | WebApp[] | 拉取/返回 webAPP 清单(含缓存逻辑) |
| `openWebApp(id)` | webAPP id | void | 打开 webAPP(新建内容 WebView 标签页 + 显示顶栏) |
| `closeWebApp(id)` | webAPP id | void | 关闭指定标签页 |
| `switchTab(id)` | 标签 id | void | 切换可见标签页 |
| `listTabs()` | 无 | Tab[] | 返回已打开标签列表 |
| `webGoBack()` | 无 | void | 当前标签回退 |
| `webGoForward()` | 无 | void | 当前标签前进 |
| `webReload()` | 无 | void | 当前标签重载 |
| `backToHome()` | 无 | void | 关闭全部标签,回到桌面 |
| `fetchCards()` | 无 | Card[] | 拉取/返回卡片目录 |
| `getDeviceInfo()` | 无 | DeviceInfo | 分辨率、density、深色模式等 |
| `launchInBounds(pkg, x,y,w,h)` | 二期 | void | freeform 启动到指定区域 |
### 4.2 原生 → H5(事件推送)
原生通过 `HearthEvents`JS 注入回调)推送事件:
| 事件 | 载荷 | 触发时机 |
|---|---|---|
| `media-session-changed` | MediaInfo | 媒体播放状态/歌曲变化 |
| `theme-changed` | `"light"\|"dark"` | 系统夜间模式切换 |
| `webapp-updated` | WebApp[] | webAPP 清单更新 |
### 4.3 数据结构
```jsonc
// AppInfo
{ "packageName": "com.x.y", "label": "音乐", "icon": "data:image/png;base64,..." }
// WebApp
{
"id": "cloud-music", "name": "云音乐",
"icon": "https://host/icon.svg", "url": "https://host/app/index.html",
"offline": { "package": "offline/cloud-music.zip", "version": "1.0.0" }
}
// MediaInfo
{ "title": "海阔天空", "artist": "Beyond", "album": "乐与怒",
"cover": "data:image/png;base64,...", "position": 120000, "duration": 245000,
"playing": true, "packageName": "com.x.player" }
// DeviceInfo
{ "widthPx": 800, "heightPx": 480, "density": 2.0, "darkMode": true }
```
## 5. 卡片插件系统
### 5.1 卡片包结构
```
cards/<card-id>/
├── card.html # 卡片 UI(独立 HTML 片段)
├── manifest.json # 声明数据源/权限
└── assets/ # 卡片私有资源(可选)
```
### 5.2 manifest 格式
```jsonc
{
"id": "media-card",
"name": "媒体卡片",
"version": "1.0.0",
"entry": "card.html",
"dataSources": ["time", "media-session"], // 需要的数据源
"permissions": ["MEDIA_CONTENT_CONTROL"], // 对应的系统权限
"priority": 1, // 优先级(数字小者优先占位)
"size": { "min": "1x1", "max": "2x2" } // 尺寸偏好
}
```
### 5.3 数据源类型
原生能力层提供的「数据源」,卡片通过 manifest 声明所需:
| 数据源 | 说明 | 权限 |
|---|---|---|
| `time` | 时间/日期 | 无 |
| `media-session` | 媒体状态 | `MEDIA_CONTENT_CONTROL` + 通知监听授权 |
| `weather` | 天气 | 网络 + 城市配置 |
| `calendar` | 日历 | `READ_CALENDAR` |
| `system` | 系统信息(内存/存储) | 无/部分 |
### 5.4 卡片加载与降级
1. H5 获取卡片目录 → 下载卡片资源到本地缓存。
2. 按 `priority` 排序,三栏容器依次放置。
3. 卡片数据源未授权/无数据时,该卡片不渲染,由低优先级卡片(日历/天气)填充。
4. 大字时间卡为**内置卡片**,始终保留,不参与降级。
## 6. webAPP 下发协议
### 6.1 清单格式(manifest.json
```jsonc
{
"version": 1,
"updatedAt": 1723785600,
"apps": [
{
"id": "cloud-music", "name": "云音乐",
"icon": "https://host/icon.svg", "url": "https://host/app/index.html",
"offline": { "package": "offline/cloud-music.zip", "version": "1.0.0", "size": 1048576 }
}
]
}
```
### 6.2 下发与缓存策略
1. 启动/定时拉取清单 → 对比本地 `version`
2. 有新版本 → 下载清单 + 各 webAPP 图标/离线包。
3. 本地缓存失败降级:**有旧清单用旧清单,无清单显示空态 + 错误提示**。
4. webAPP 打开时:有离线包且离线 → 加载本地解压目录;否则加载远程 URL。
### 6.3 webAPP 打开行为(浏览器式顶栏 + 多标签)
- 打开 webAPP → 原生新建内容 WebView(标签页)加载,桌面 H5 顶部渲染浏览器式顶栏。
- 顶栏:回退 / 前进 / 重载(作用于当前标签);右侧标签页入口,可切换 / 关闭已打开标签。
- 每个标签页 = 一个独立 WebView,拥有独立前进后退历史。
- 关闭全部标签 = 回到桌面。
## 7. UI 设计
### 7.1 视觉规范(Miuix / HyperOS 风格,CSS 还原)
- **配色**:双色 token(CSS 变量),浅色/深色两套。
- **壁纸透明**`html`/`body` 背景透明以透出壁纸。
- **卡片玻璃拟态(HyperOS 4 柔光玻璃)**:卡片、侧边栏、列表容器等 UI 元素用半透明背景 + `backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1)`(柔光玻璃 = 高斯模糊 + 饱和度/亮度/对比度微调,参数参考 Miuix `miuix-blur`);深色 `rgba(21,21,24,0.55)`、浅色 `rgba(255,255,255,0.4)` 半透明混合层;配 1px 半透明描边;旧 WebView 不支持 `backdrop-filter` 时降级为纯半透明背景。
- **圆角**squircle 平滑圆角(卡片 14px 左右)。
- **字体**MiSans(远程字体或系统默认)。
- **强调色**:小米橙 `#ff6900`(选中态/进度条)。
- **侧边栏选中态**:橙色高亮药丸。
### 7.2 页面布局
| 页面 | 布局 |
|---|---|
| 首页 | 三栏卡片流:大字时间(始终)+ 媒体卡(动态)+ 日历/天气/小工具(填充) |
| 沉浸首页(二期) | 左 2/3 freeform 窗口 + 右卡片列(时间/媒体/天气) |
| 安卓 APP 列表 | 顶部搜索框 + 图标网格(图标+名称) |
| H5 应用列表 | 顶部搜索框 + webAPP 富卡片(缩略图 + 在线/离线标签) |
| 设置 | Miuix Preference 分组:显示 / 网络 / 关于 |
### 7.3 侧边栏
- 收起:80px,纯图标(无文字)。
- 展开:240px,图标 + 文字横排,选中项后橙色高亮药丸。
- 左上角按钮切换收起/展开。
- **非首页时**:侧边栏最顶部(Header 区域)显示一个小时间;首页时不显示。
### 7.4 夜间模式
- H5 用 CSS 变量 + `prefers-color-scheme` 跟随系统。
- 原生保证 WebView 跟随 `Configuration.uiMode`,无需额外处理。
### 7.5 webAPP 顶栏
- 左侧:回退、前进、重载三个图标按钮。
- 右侧:标签页入口按钮(点击展开标签列表,可切换 / 关闭)。
- 顶栏随 webAPP 打开而显示,关闭全部标签后隐藏。
## 8. 窗口管理
- **全屏沉浸式**`WindowInsetsController.hide(statusBars() | navigationBars())`,行为 `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`(系统默认:下滑一次展开、几秒自动收起)。
- **横屏锁定**`screenOrientation = landscape`
- **默认桌面**Manifest 声明 `HOME` + `DEFAULT` intent-filter,用户手动设为默认。
- **Back 键屏蔽**:桌面状态下拦截 Back 键(按返回无响应);webAPP 打开时 Back 先作用于标签页回退,无历史可回退则关闭标签。
- **壁纸显示**Activity 窗口开启壁纸(theme `showWallpaper` / `WallpaperManager`),WebView `setBackgroundColor(TRANSPARENT)`,配合 H5 页面透明透出壁纸。
## 9. 权限模型
| 权限 | 用途 | 授予方式 |
|---|---|---|
| `QUERY_ALL_PACKAGES` | 查询已装应用列表 | launcher 豁免,声明即生效 |
| `BIND_NOTIFICATION_LISTENER_SERVICE` | 读取媒体通知(媒体卡数据源) | 用户手动授权「通知使用权」 |
| `READ_CALENDAR` | 日历卡片数据 | 运行时授权 |
| `INTERNET` | 拉取 webAPP/卡片 | 声明即生效 |
## 10. 错误处理
| 场景 | 处理 |
|---|---|
| webAPP 清单拉取失败 | 用本地缓存清单;无缓存则空态 + 提示 |
| 离线包下载失败 | 标记「在线」,下次拉取重试 |
| 媒体权限未授权 | 媒体卡不渲染,降级日历/天气 |
| WebView 加载失败 | 错误页 + 重试按钮 |
| 系统 WebView 缺失 | 启动检测,提示(或引导安装) |
| 第三方 App 无法 freeform(二期) | 捕获异常,提示该 App 不支持小窗 |
## 11. 测试策略
- **TDD**:先写测试再写实现。
- **原生单测**Repository 层(mock PackageManager / 网络 / CacheManager)。
- **JS Bridge 协议测试**:mock H5 调用,验证方法签名与返回结构。
- **H5 侧**:卡片/页面为纯静态,用轻量 JS 单测覆盖数据拼接与降级逻辑。
- **真机集成**:adb 安装到板子,验证 HOME、沉浸式、媒体权限、webAPP 拉取。
## 12. 技术栈与依赖
| 项 | 选择 |
|---|---|
| 语言 | Kotlin |
| SDK | minSdk 30 / targetSdk 30 |
| WebView | 系统内核(需上板验证存在) |
| 网络 | OkHttp |
| JSON | Gson |
| H5 | 纯 HTML/CSS/JS,无框架(SPA 手写) |
| 构建 | GradleAndroid Gradle Plugin |
## 13. 二期规划(不在本期实现)
1. **freeform 小窗**(沉浸首页):`FreeformWindowManager` 模块 + `launchInBounds` 能力。
- 前提:固件开启 `enable_freeform_support`,需上板验证。
2. 更多首页候选卡片(随卡片插件系统扩展)。
## 14. 明确不做(YAGNI
- 完整 kiosk 锁定(Home / 最近任务 / 系统手势全锁,仅 Back 键已做)
- 屏幕常亮管理
- 多用户 / Widget 宿主
- 应用图标拖拽 / 文件夹 / Dock
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
android.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/34.0.0/aapt2
# 禁用 AGP 自动下载 SDK 组件(本机无法访问 dl.google.com,避免配置阶段卡死)
# Disable AGP auto-download of SDK components (dl.google.com unreachable, avoid hang during config)
android.builder.sdkDownload=false
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+38
View File
@@ -0,0 +1,38 @@
// cards.js 纯函数测试:三栏降级布局(time 卡恒第一列、disabled 卡跳过、priority 升序分配)
// cards.js pure-function tests: three-column fallback layout
// (time always first column, disabled cards skipped, priority ascending)
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');
});
test('priority ascending fills columns round-robin', () => {
const cols = layout([
{ id: 'c', priority: 3 },
{ id: 'a', priority: 1 },
{ id: 'b', priority: 2 },
]);
assert.deepStrictEqual(cols, [['a'], ['b'], ['c']]);
});
test('empty input yields three empty columns', () => {
assert.deepStrictEqual(layout([]), [[], [], []]);
});
+37
View File
@@ -0,0 +1,37 @@
// tabs.js 纯函数测试:不可变风格,验证 add/remove/switchTo
// tabs.js pure-function tests: immutable style, verify add/remove/switchTo
const { test } = require('node:test');
const assert = require('node:assert');
const { add, remove, switchTo } = require('../app/src/main/assets/h5/js/webapp/tabs.js');
test('add 不存在时新增并激活,已存在时不重复', () => {
let tabs = [];
tabs = add(tabs, 'a', '云音乐');
tabs = add(tabs, 'b', '天气');
tabs = add(tabs, 'a', '云音乐');
assert.strictEqual(tabs.length, 2, '重复 id 不应重复添加');
assert.strictEqual(tabs.filter(t => t.active).length, 1, '仅一个激活标签');
assert.strictEqual(tabs.find(t => t.active).id, 'a', '重复添加应重新激活该 id');
});
test('add 不修改传入数组(不可变)', () => {
const before = [];
const after = add(before, 'a', 'x');
assert.strictEqual(before.length, 0, '原数组不应被改动');
assert.strictEqual(after.length, 1);
});
test('remove 删除指定标签', () => {
let tabs = [{ id: 'a', name: 'x', active: false }, { id: 'b', name: 'y', active: true }];
tabs = remove(tabs, 'a');
assert.strictEqual(tabs.length, 1);
assert.strictEqual(tabs[0].id, 'b');
});
test('switchTo 激活指定 id 且不修改原数组', () => {
const before = [{ id: 'a', name: 'x', active: true }, { id: 'b', name: 'y', active: false }];
const after = switchTo(before, 'b');
assert.strictEqual(after.find(t => t.id === 'b').active, true);
assert.strictEqual(after.filter(t => t.active).length, 1);
assert.strictEqual(before.find(t => t.id === 'b').active, false, '原数组不应被改动');
});
+18
View File
@@ -0,0 +1,18 @@
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")