Compare commits

...

73 Commits

Author SHA1 Message Date
AskaEth 885be2fe85 feat: disable webview long-press menu on launcher pages except inputs 2026-08-17 21:55:37 +08:00
AskaEth 8b9a707cce chore: CSS zoom scale comment cleanup 2026-08-17 21:53:04 +08:00
AskaEth 06377f067f fix: per-app scale via CSS zoom, hide download icon when local version matches 2026-08-17 21:40:06 +08:00
AskaEth 0194a57734 feat: download icon on webapp cards for auto-install 2026-08-17 20:18:39 +08:00
AskaEth a1b65ad4c6 fix: view-level webview scale, close menu on outside tap/page switch 2026-08-17 20:16:32 +08:00
AskaEth 69bd90d23a fix: disable overview mode for per-app scale to take effect 2026-08-17 20:12:21 +08:00
AskaEth fb177cc30f feat: check-update diff report and list refresh 2026-08-17 20:09:15 +08:00
AskaEth 2e0e1d5d47 feat: install-locally button, log delete result 2026-08-17 20:05:39 +08:00
AskaEth 5901ce98e2 feat: add reinstall button to long-press menu 2026-08-17 20:04:16 +08:00
AskaEth bb714d8de3 feat: per-app initial scale config 2026-08-17 20:01:01 +08:00
AskaEth b65a0400a6 fix: allow file access for local offline packages 2026-08-17 19:54:06 +08:00
AskaEth 24c78bb9c2 feat: per-app UA config, fix demo file path and back-loop 2026-08-17 19:51:54 +08:00
AskaEth 5498c8ed0b fix: resolve relative manifest paths, base64 demo icon 2026-08-17 19:44:53 +08:00
AskaEth a35a5018b7 fix: fetch manifest from <server>/manifest.json 2026-08-17 19:41:08 +08:00
AskaEth 2d2dee5e51 fix: allow cleartext HTTP for local/network servers 2026-08-17 19:33:43 +08:00
AskaEth 31107fddd5 feat: log export to Download 2026-08-17 19:25:43 +08:00
AskaEth d9a0342271 fix: add confirm button for server url input 2026-08-17 19:11:37 +08:00
AskaEth 811e2c9716 feat: offline package version management, long-press menu 2026-08-17 18:02:53 +08:00
AskaEth 49b84f83ad feat: offline package download/import, load progress, local-first open 2026-08-17 13:11:32 +08:00
AskaEth 010076ff28 docs: add device debugging and UI iteration log 2026-08-17 12:51:33 +08:00
AskaEth 8d7e015857 fix: cache media cover to avoid vanishing on swipe 2026-08-17 12:49:38 +08:00
AskaEth 17d573e589 fix: visible music-icon placeholder when media has no cover 2026-08-17 12:48:03 +08:00
AskaEth d1d903278e feat: draggable progress bar (seek) and settings sliders 2026-08-17 12:20:05 +08:00
AskaEth 91665bbbd5 fix: restore webapp list visibility on page switch 2026-08-16 21:45:34 +08:00
AskaEth 9fc3272923 feat: native tab panel overlay, webapp height no longer reserved 2026-08-16 21:41:05 +08:00
AskaEth 0154ebc999 fix: increase top reserve so tab panel fully clears content webview 2026-08-16 21:38:01 +08:00
AskaEth b901afc901 fix: horizontal tab panel in top reserve area, no longer covered 2026-08-16 21:35:44 +08:00
AskaEth bc08a8e4c3 fix: hide webapp list content while a webapp is open 2026-08-16 21:33:38 +08:00
AskaEth e0add95d7a fix: webapp topbar spans full content width 2026-08-16 21:28:53 +08:00
AskaEth e34a67aeea fix: media card keeps span-2 after swipe 2026-08-16 21:26:55 +08:00
AskaEth 9f8ca61374 fix: bold time card, tab panel position above content webview 2026-08-16 21:25:37 +08:00
AskaEth 6020b89215 feat: time card font scales to half card short side, centered 2026-08-16 21:22:00 +08:00
AskaEth 1bec58420d fix: progress bar padding, time card stacked layout 2026-08-16 21:18:41 +08:00
AskaEth f3f52030f4 fix: media progress bar initial width and duration labels 2026-08-16 21:15:00 +08:00
AskaEth 729b4ba302 fix: fixed media title height to avoid layout jump on lyrics change 2026-08-16 21:13:06 +08:00
AskaEth 37979119b7 fix: align media controls center, add media duration logging 2026-08-16 21:11:45 +08:00
AskaEth ed8471a9e7 perf: in-place media card update for live lyrics 2026-08-16 21:10:08 +08:00
AskaEth 18a237bd1f feat: live lyrics from notification title, fix topbar overlap 2026-08-16 21:06:08 +08:00
AskaEth fe2dcabdd4 feat: hide foreground webapp when switching sidebar pages 2026-08-16 20:27:17 +08:00
AskaEth 7a28e2372c fix: grid minmax, media polling fallback, content webview layout, swipe timeout 2026-08-16 20:25:21 +08:00
AskaEth 8d10d9e5e9 feat: media card polish - per-index controls, cover jump, wrap, slide transition 2026-08-16 20:13:47 +08:00
AskaEth c82418f846 feat: bundle demo webapp in apk 2026-08-16 20:00:26 +08:00
AskaEth 5f5d0431ee feat: media card cover, controls, live progress, multi-session swipe 2026-08-16 19:59:30 +08:00
AskaEth 8c42dca7bc feat: page preload, card grid span, wallpaper into webview for glass blur 2026-08-16 19:51:08 +08:00
AskaEth 1a79b4ed55 fix: media card survives page switch, cards fill column height 2026-08-16 18:47:29 +08:00
AskaEth 6915c54832 feat: glass effect toggle, media refresh timing fix, page transition perf 2026-08-16 18:42:04 +08:00
AskaEth a65205b8d2 fix: pull active media session on start, icon centering, tap highlight, media card 2026-08-16 18:33:14 +08:00
AskaEth c9dcb88647 feat: settings interactions (theme/mask/brightness/server-url), svg icons, seconds display, page transition 2026-08-16 18:25:28 +08:00
AskaEth e2a289a4b0 feat: add notification-access setting item with jump 2026-08-16 18:12:06 +08:00
AskaEth 8e722dc545 fix: catch SecurityException when notification access not granted 2026-08-16 18:10:26 +08:00
AskaEth 160274e218 fix: enter immersive mode after setContentView to avoid DecorView NPE 2026-08-16 18:08:52 +08:00
AskaEth ebcd771a1f fix: add LAUNCHER category and page-finished log 2026-08-16 17:59:40 +08:00
AskaEth a39264d785 docs: add development log 2026-08-16 17:50:28 +08:00
AskaEth 7ce4df15e6 fix: synchronize webapp container state access 2026-08-16 17:49:38 +08:00
AskaEth f7cffe27d9 fix: final review findings - thread safety, xss, cache 2026-08-16 17:35:13 +08:00
AskaEth a0f8873b7a fix: marshal js bridge view ops to main thread 2026-08-16 17:21:02 +08:00
AskaEth 6ca55dbe5f feat: webapp multi-tab JS bridge and multi-WebView management 2026-08-16 17:13:48 +08:00
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
45 changed files with 4941 additions and 28 deletions
+13 -1
View File
@@ -3,16 +3,28 @@
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<application
android:label="Hearth"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape">
android:screenOrientation="landscape"
android:theme="@style/Theme.Hearth">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<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>
+846
View File
@@ -0,0 +1,846 @@
/* Hearth 桌面样式:侧边栏 + 5 页框架 + 柔光玻璃容器 */
/* Hearth launcher styles: sidebar + 5-page frame + soft-glass containers */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
/* 移除 WebView 默认蓝色点击高亮,改用下方 :active 的 Miuix 风格反馈 */
/* Remove the default blue tap highlight; use the Miuix-style :active feedback below */
-webkit-tap-highlight-color: transparent;
/* 禁用文本选择,避免长按弹出 WebView 选择/复制菜单(输入框除外) */
/* Disable text selection to avoid the long-press WebView context menu (except inputs) */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
/* 输入框/文本域/可编辑区保留文本选择,长按可复制粘贴 */
/* Inputs / textareas / editable areas keep text selection for copy/paste */
input, textarea, [contenteditable="true"] {
-webkit-user-select: text;
user-select: text;
-webkit-touch-callout: default;
}
html, body { height: 100%; }
/* 壁纸透明:html/body 不设背景,露出原生壁纸 */
/* Wallpaper transparency: html/body keep transparent to reveal native wallpaper */
html, body { background: transparent; }
/* 背景遮罩层:夜间主题下盖在壁纸上、UI 之下,透明度由 mask.js 控制 */
/* Wallpaper dim overlay: sits above the wallpaper and below the UI; opacity is
controlled by mask.js */
#wallpaper-dim {
position: fixed;
inset: 0;
background: #000;
opacity: 0;
pointer-events: none;
z-index: 0;
transition: opacity .3s;
}
body {
color: var(--text);
font-family: -apple-system, "MiSans", "PingFang SC", sans-serif;
overflow: hidden;
}
.app { position: relative; z-index: 1; display: flex; height: 100vh; }
/* 侧边栏:柔光玻璃容器,非纯色背景 */
/* Sidebar: soft-glass container, not a solid color */
.rail {
width: 80px;
background: var(--glass-bg);
backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: var(--blur-filter);
border-right: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
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);
height: 18px;
line-height: 18px;
/* 始终占位固定高度,首页隐藏文字但保留高度,避免撑动下方图标 */
/* Always occupy a fixed height; hide text on home but keep the height to
avoid shifting the icons below */
visibility: hidden;
}
.rail-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
gap: 3px;
background: none;
border: none;
color: var(--text-dim);
padding: 8px;
border-radius: 16px;
cursor: pointer;
}
.rail-item .ico {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.rail-item .ico svg { width: 100%; height: 100%; display: block; }
.rail-item .lbl { font-size: 11px; text-align: center; width: 100%; }
.rail-item.active { background: var(--accent); color: #fff; }
/* Miuix 风格点击反馈:轻按压时整体轻微变淡,替代默认蓝色高亮 */
/* Miuix-style press feedback: a subtle fade on press instead of the blue highlight */
.rail-item:active,
.cell:active,
.wcell:active,
.st-item:active {
opacity: 0.6;
transition: opacity .12s ease;
}
.content { flex: 1; overflow: hidden; }
.page { display: none; height: 100%; }
.page.active {
display: flex;
flex-direction: column;
/* 页面切换淡入 + 轻微上移动画 */
/* Fade-in + slight rise transition on page switch */
animation: page-fade .22s ease;
}
@keyframes page-fade {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
/* 首页卡片 Grid:三等分列,卡片可跨栏(span-2/3),行均分高度 */
/* Home card grid: three equal columns, cards can span (span-2/3), rows split evenly */
.tri-col {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 1fr;
gap: 16px;
padding: 16px;
overflow-y: auto;
}
.card.span-2 { grid-column: span 2; }
.card.span-3 { grid-column: span 3; }
/* 卡片:柔光玻璃拟态(复用 Task 4 token,非纯色背景) */
/* Card: soft-glass morphism (reuse Task 4 tokens, not a solid color) */
.card {
/* 卡片在栏内自动拉伸填满高度(多卡均分) */
/* Cards stretch to fill the column height (even split when multiple) */
flex: 1 1 0;
min-height: 0;
min-width: 0; /* 防止内容(长标题/歌词)撑开 Grid 列宽 */
background: var(--glass-bg);
backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: 14px;
padding: 20px;
color: var(--text);
transition: transform .18s ease;
}
/* 卡片非交互区域的触摸按压反馈:轻微缩放,避免生硬 */
/* Touch/press feedback on the card's non-interactive area: subtle scale */
.card:active {
transform: scale(0.985);
}
/* 大字时间卡:主时间大字 + 日期副行 */
/* Big time card: large clock + date subtitle */
/* 时钟卡缩放权重小:占栏高比例小于其他卡片 */
/* Time card grows less: takes a smaller share of column height than other cards */
.time-card {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
/* 小时 / 分钟:上下两行大字,等宽数字避免宽度抖动;
font-size 由 JS 按「卡片短边一半」动态设置 */
/* Hour / minute: stacked large lines, tabular numerals avoid width jitter;
font-size is set by JS to half the card's short side */
.time-card .h,
.time-card .m {
font-weight: 600;
line-height: 1.05;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* 秒显:小号、弱化,位于分钟下方 */
/* Seconds: small and dim, below the minute */
.time-card .sec {
font-size: 18px;
font-weight: 300;
color: var(--text-dim);
margin-top: 4px;
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* 年月日星期:靠卡片底部,填充高处留白 */
/* Full date: pinned to the card bottom, filling the tall space */
.time-card .date-full {
margin-top: auto;
padding-top: 8px;
font-size: 13px;
color: var(--text-dim);
}
/* 媒体卡:flex column,信息区靠上、进度/控制靠下 */
/* Media card: flex column, info top, progress/controls bottom */
.media-card {
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 媒体卡布局:默认上下(封面在上、信息在下),横条时左右(.wide) */
/* Media layout: vertical (cover on top, info below) by default; horizontal
(cover left, info right) when the card is wider than tall (.wide) */
.media-card .media-layout {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
flex: 1;
min-height: 0;
text-align: center;
}
.media-card.wide .media-layout {
flex-direction: row;
text-align: left;
}
/* 封面:自适应,随卡片变大;可点击跳转播放界面 */
/* Cover: adaptive size, grows with the card; tappable to open the playback UI */
.media-card .cover {
width: 96px;
height: 96px;
max-width: 100%;
border-radius: 16px;
object-fit: cover;
flex: none;
cursor: pointer;
transition: transform .18s ease;
}
.media-card .cover:active {
transform: scale(0.92);
}
.media-card .cover-ph {
background: linear-gradient(135deg, var(--accent), rgba(255, 157, 0, .6));
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.media-card .cover-ph svg {
width: 40%;
height: 40%;
}
.media-card.wide .cover {
width: 120px;
height: 120px;
}
/* 信息区 */
/* Info area */
.media-card .media-info {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
/* 标题:自动换行(歌词/长标题),最多 3 行省略;
固定 3 行高度避免歌词长短变化导致下方进度条/按钮上下跳动 */
/* Title: wraps automatically (lyrics / long titles), max 3 lines with ellipsis;
fixed 3-line height so the progress/controls below don't jump as lyrics change */
.media-card .tt {
margin-top: 6px;
font-size: 18px;
font-weight: 600;
line-height: 1.3;
min-height: 3.9em;
white-space: normal;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.media-card .ar {
margin-top: 2px;
font-size: 13px;
color: var(--text-dim);
white-space: normal;
word-break: break-word;
}
/* 进度条 */
/* Progress bar */
.media-card .prog {
margin-top: auto;
/* 不要用 padding-top:全局 box-sizing:border-box 下 padding 会吃掉 height
导致 .bar 的 height:100% 变成 0 */
/* No padding-top: with box-sizing:border-box the padding eats the height,
making .bar's height:100% zero */
height: 4px;
border-radius: 999px;
background: var(--glass-border);
overflow: hidden;
}
.media-card .bar {
height: 100%;
border-radius: 999px;
background: var(--accent);
transition: width .3s linear;
}
/* 时长文字:当前时长 / 总时长 */
/* Time labels: current / total duration */
.media-card .time-row {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
}
/* 无时长信息时的活动脉冲进度条 */
/* Indeterminate pulse when no duration is available */
.media-card .bar.indeterminate {
width: 40%;
animation: bar-pulse 1.5s ease-in-out infinite;
}
@keyframes bar-pulse {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(150%); }
}
/* 控制按钮:SVG 图标 + 按压动画 */
/* Controls: SVG icons + press animation */
.media-card .controls {
margin-top: 12px;
display: flex;
gap: 10px;
justify-content: center;
/* 垂直居中:中间播放按钮直径更大,对齐圆心而非顶部 */
/* Center vertically: the play button is larger, align centers not tops */
align-items: center;
}
.media-card .controls button {
width: 40px;
height: 40px;
border: none;
border-radius: 999px;
background: var(--glass-border);
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform .15s ease, background .15s ease, opacity .15s ease;
}
.media-card .controls button svg {
width: 20px;
height: 20px;
}
.media-card .controls button.play {
width: 48px;
height: 48px;
background: var(--accent);
color: #fff;
}
.media-card .controls button:active {
transform: scale(0.82);
opacity: 0.7;
}
/* 媒体卡横划切换的方向过渡动画 */
/* Directional slide transitions for the media card swipe */
@keyframes slide-out-left {
to { transform: translateX(-40%); opacity: 0; }
}
@keyframes slide-out-right {
to { transform: translateX(40%); opacity: 0; }
}
@keyframes slide-in-left {
from { transform: translateX(-40%); opacity: 0; }
to { transform: none; opacity: 1; }
}
@keyframes slide-in-right {
from { transform: translateX(40%); opacity: 0; }
to { transform: none; opacity: 1; }
}
.media-card.slide-out-left { animation: slide-out-left .2s ease forwards; }
.media-card.slide-out-right { animation: slide-out-right .2s ease forwards; }
.media-card.slide-in-left { animation: slide-in-left .25s ease; }
.media-card.slide-in-right { animation: slide-in-right .25s ease; }
/* 安卓 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;
/* 行靠顶部对齐,避免结果只有一行时被拉伸到接近容器高度 */
/* Align rows to the top so a single result row doesn't stretch to container height */
align-content: start;
}
.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);
}
/* 下载离线包图标(右下角,云端有离线包时显示) */
/* Download-offline-package icon (bottom-right, shown when cloud has a package) */
.wcell-dl {
position: absolute;
bottom: 6px;
right: 6px;
width: 26px;
height: 26px;
border-radius: 999px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.wcell-dl svg {
width: 14px;
height: 14px;
}
.wcell-dl:active {
opacity: 0.6;
}
/* webAPP 顶栏:浮层玻璃条 + 标签面板 */
/* webapp topbar: floating glass bar + tab panel */
/* 前台有 webapp 时隐藏 webapp 列表内容,避免半透明顶栏透出列表控件 */
/* Hide the webapp list content while a webapp is open, so the translucent topbar
doesn't show the list controls through it */
body.webapp-active [data-page="webapplist"] {
visibility: hidden;
}
/* webapp 加载/下载进度覆盖层 */
/* webapp load/download progress overlay */
#webapp-loading {
position: fixed;
inset: 0;
z-index: 200;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, .4);
}
#webapp-loading.show { display: flex; }
#webapp-loading .wl-box {
min-width: 220px;
padding: 20px 24px;
border-radius: 16px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
display: flex;
flex-direction: column;
gap: 12px;
}
#webapp-loading .wl-text {
font-size: 14px;
color: var(--text);
text-align: center;
}
#webapp-loading .wl-bar {
height: 4px;
border-radius: 999px;
background: var(--glass-border);
overflow: hidden;
}
#webapp-loading .wl-fill {
height: 100%;
width: 0;
border-radius: 999px;
background: var(--accent);
transition: width .2s ease;
}
/* webapp 长按管理菜单 */
/* webapp long-press management menu */
.wapp-menu {
position: fixed;
z-index: 300;
min-width: 180px;
padding: 8px;
border-radius: 16px;
background: var(--glass-bg);
backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border);
box-shadow: 0 4px 20px rgba(0, 0, 0, .3);
}
.wapp-menu-item {
padding: 10px 12px;
border-radius: 10px;
font-size: 14px;
color: var(--text);
cursor: pointer;
}
.wapp-menu-item:active {
background: var(--glass-border);
}
#web-topbar {
position: fixed;
top: 12px;
/* 占满内容 WebView 宽度(侧边栏右侧到屏幕右),遮住下方桌面内容(如搜索框),
避免顶栏两侧露出可点击的桌面元素 */
/* Span the content WebView width (sidebar's right edge to the screen's right)
to cover the desktop content below (e.g. the search box) so no clickable
desktop element peeks through beside the topbar */
left: 84px;
right: 12px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 16px;
background: var(--glass-bg);
backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: var(--blur-filter);
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;
/* 顶栏正下方横向展开,位于内容 WebView 的顶部预留区(96dp)内,不被遮挡 */
/* Horizontal row right below the topbar, inside the content WebView's top
reserve (96dp) so it isn't covered */
top: 54px;
left: 84px;
right: 12px;
display: none;
flex-direction: row;
gap: 8px;
overflow-x: auto;
padding: 8px;
border-radius: 16px;
background: var(--glass-bg);
backdrop-filter: var(--blur-filter);
-webkit-backdrop-filter: var(--blur-filter);
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: var(--blur-filter);
-webkit-backdrop-filter: var(--blur-filter);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
color: var(--text);
}
.st-item .lbl { font-size: 15px; }
/* 服务器地址输入框:点击设置项后展开 */
/* Server URL input: expanded inside the item on tap */
.url-input {
flex: 1;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text);
font-size: 13px;
outline: none;
}
/* 服务器地址确认按钮 */
/* Server URL save button */
.url-save {
flex: none;
padding: 6px 14px;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-size: 13px;
cursor: pointer;
}
.url-save:active {
opacity: 0.7;
}
/* 右侧箭头:弱化色 */
/* 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: var(--fill, 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);
}
+43
View File
@@ -0,0 +1,43 @@
/* 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;
/* 玻璃效果默认关闭:无模糊、无高光(普通半透明) */
/* Glass effect off by default: no blur, no highlight (plain translucent) */
--blur-filter: none;
--glass-shadow: none;
}
/* 玻璃效果开启:磨砂模糊 + 边缘反射高光(液态玻璃,参考 Apple WWDC25 */
/* Glass effect on: frosted blur + edge-reflection highlight (liquid glass) */
:root[data-glass="on"] {
--blur-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
--glass-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 -1px 0 rgba(255, 255, 255, 0.04);
}
/* 系统深色:仅在未强制浅色时生效(跟随系统) */
/* System dark: only applies when not forced light (follow-system) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--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;
}
}
/* 强制深色:无论系统主题 */
/* Forced dark: regardless of system theme */
:root[data-theme="dark"] {
--glass-bg: rgba(21, 21, 24, 0.55);
--glass-border: rgba(255, 255, 255, 0.08);
--text: #ffffff;
--text-dim: #9a9aa0;
--accent: #ff6900;
}
+85 -5
View File
@@ -1,12 +1,92 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hearth</title>
<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">
<script src="js/theme.js"></script>
<script src="js/glass.js"></script>
<script>
// 阻止非输入框的 WebView 长按菜单(文本/图片的复制、保存等),
// 输入框/文本域/可编辑区保留系统菜单以便复制粘贴
// Block the WebView long-press context menu on non-input elements (copy/save
// image); keep it for inputs/textareas/contenteditable for copy/paste
document.addEventListener('contextmenu', function (e) {
var t = e.target;
var tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || t.isContentEditable) return;
e.preventDefault();
}, true);
</script>
</head>
<body>
<h1>Hearth Launcher</h1>
<p>占位页面,后续由 WebView 加载。</p>
<div id="wallpaper-dim"></div>
<div id="webapp-loading">
<div class="wl-box">
<div class="wl-text">加载中…</div>
<div class="wl-bar"><div class="wl-fill"></div></div>
</div>
</div>
<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/preload.js"></script>
<script src="js/mask.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');
// 初始化背景遮罩(夜间主题 + 已启用时生效)
// Apply wallpaper dim on startup (active under dark theme when enabled)
window.mask.apply();
// 加载壁纸为 body 背景:玻璃效果 backdrop-filter 需要 WebView 内部有背景可模糊
// Load the wallpaper as the body background: the glass backdrop-filter needs
// in-WebView content behind cards to blur
bridge.call('getWallpaper').then(wp => {
if (wp) {
document.body.style.backgroundImage = 'url(' + wp + ')';
document.body.style.backgroundSize = 'cover';
document.body.style.backgroundPosition = 'center';
}
}).catch(() => {});
// 首屏渲染后延迟预加载其他页面数据,减少切页等待
// Preload other pages' data after first paint to reduce switch latency
setTimeout(() => window.preload.loadAll(), 800);
</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 };
});
+24
View File
@@ -0,0 +1,24 @@
// 玻璃效果开关:普通半透明(默认)/ 液态玻璃(backdrop-filter 磨砂 + 边缘高光)
// Glass effect toggle: plain translucent (default) / liquid glass (backdrop-filter
// frost + edge highlight), persisted in localStorage
(function () {
const KEY = 'hearth-glass';
window.glass = {
enabled() {
return localStorage.getItem(KEY) === '1';
},
setEnabled(on) {
localStorage.setItem(KEY, on ? '1' : '0');
this.apply();
},
// 应用开关:写 data-glass 属性,CSS 据此切换 --blur-filter 等变量
// Apply: set the data-glass attribute; CSS switches --blur-filter etc. from it
apply() {
document.documentElement.setAttribute('data-glass', this.enabled() ? 'on' : 'off');
},
};
// 启动即应用(默认关闭 = 普通半透明)
window.glass.apply();
})();
+40
View File
@@ -0,0 +1,40 @@
// 背景遮罩:夜间主题下给壁纸加暗色遮罩,可开关 + 调明暗度(存 localStorage
// Wallpaper dim: darkens the wallpaper under dark theme, with toggle + opacity
// control (persisted in localStorage)
(function () {
const KEY_ON = 'hearth-mask-enabled';
const KEY_ALPHA = 'hearth-mask-alpha';
// 判断当前是否深色主题(强制深色,或跟随系统且系统为深色)
// Whether the current theme is dark (forced dark, or follow-system + system dark)
function isDark() {
const t = window.theme.get();
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
window.mask = {
enabled() {
return localStorage.getItem(KEY_ON) === '1';
},
alpha() {
return parseFloat(localStorage.getItem(KEY_ALPHA) || '0.55');
},
setEnabled(on) {
localStorage.setItem(KEY_ON, on ? '1' : '0');
this.apply();
},
setAlpha(a) {
localStorage.setItem(KEY_ALPHA, String(a));
this.apply();
},
// 应用遮罩:仅深色主题且启用时显示(透明度 = 明暗度)
// Apply: show only under dark theme when enabled (opacity = alpha)
apply() {
const el = document.getElementById('wallpaper-dim');
if (!el) return;
el.style.opacity = (isDark() && this.enabled()) ? String(this.alpha()) : '0';
},
};
})();
@@ -0,0 +1,38 @@
// 安卓 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>`;
// 优先用预加载缓存,未就绪则实时加载
// Prefer the preloaded cache; fetch live when not ready yet
const apps = window.preload.apps || JSON.parse(await bridge.call('listApps'));
const grid = document.getElementById('app-grid');
const render = (list) => {
grid.textContent = '';
// 用 createElement + textContent/dataset 渲染,避免 innerHTML 拼接用户数据(XSS
// Render with createElement + textContent/dataset to avoid innerHTML user data (XSS)
list.forEach(a => {
const btn = document.createElement('button');
btn.className = 'cell';
btn.dataset.pkg = a.packageName;
const img = document.createElement('img');
img.className = 'ic';
img.src = a.iconBase64 || '';
img.alt = '';
const lbl = document.createElement('span');
lbl.className = 'lbl';
lbl.textContent = a.label;
btn.append(img, lbl);
btn.onclick = () => bridge.call('launchApp', btn.dataset.pkg);
grid.appendChild(btn);
});
};
render(apps);
document.getElementById('app-search').oninput = (e) => {
const kw = e.target.value.toLowerCase();
render(apps.filter(a => a.label.toLowerCase().includes(kw)));
};
};
+375
View File
@@ -0,0 +1,375 @@
// 首页三栏卡片:拉取目录 -> 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>`;
let catalog = [];
try {
// 优先用预加载缓存,未就绪则实时加载
// Prefer the preloaded cache; fetch live when not ready yet
catalog = window.preload.cards || 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 });
}
renderCards(catalog);
startTimeCardTicker();
};
// 卡片 Grid 渲染:media 卡 span 2(跨栏),time/其他卡 span 1
// Card grid render: media card spans 2 columns, time/other cards span 1
function renderCards(catalog) {
const grid = document.getElementById('tri-col');
grid.textContent = '';
const items = [];
// 按 priority 排序(数字小者优先)
// Sort by priority (smaller first)
const sorted = [...catalog].sort((a, b) => (a.priority || 0) - (b.priority || 0));
// time 卡(内置,span 1
if (sorted.some(c => c.id === 'time')) items.push({ span: 1, el: renderTimeCard() });
// media 卡(有媒体时 span 2,跨栏更美观)
if (currentMediaList && currentMediaList.length > 0) {
items.push({ span: 2, el: renderMediaCard(currentMediaList[currentMediaIndex]) });
}
// 其他卡(排除 timespan 1
sorted.filter(c => c.id !== 'time').forEach(c => {
items.push({ span: 1, el: renderGenericCard(c.id) });
});
items.forEach(item => {
item.el.classList.add('span-' + item.span);
grid.appendChild(item.el);
});
}
// 内置大字时间卡:小时/分钟上下两行大字 + 秒小号 + 年月日星期
// Builtin big time card: hour/minute stacked large + small seconds + full date
function renderTimeCard() {
const el = document.createElement('div');
el.className = 'card time-card';
const now = new Date();
el.innerHTML = `
<div class="h">${fmtHour(now)}</div>
<div class="m">${fmtMinute(now)}</div>
<div class="sec">${fmtSec(now)}</div>
<div class="date-full">${fmtFullDate(now)}</div>`;
applyTimeCardSize(el);
return el;
}
// 时间卡字号自适应:HH/MM 字号 = 卡片短边的一半,随卡片尺寸变化
// Adaptive time-card font: HH/MM font-size = half the card's short side,
// tracking the card size
function applyTimeCardSize(el) {
const check = () => {
const w = el.clientWidth;
const h = el.clientHeight;
if (w <= 0 || h <= 0) return;
const size = Math.floor(Math.min(w, h) / 2);
el.querySelectorAll('.h, .m').forEach(n => { n.style.fontSize = size + 'px'; });
};
check();
requestAnimationFrame(check);
}
// 其他卡片占位:仅显示 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 fmtHour(d) {
return String(d.getHours()).padStart(2, '0');
}
function fmtMinute(d) {
return String(d.getMinutes()).padStart(2, '0');
}
function fmtSec(d) {
return String(d.getSeconds()).padStart(2, '0');
}
function fmtFullDate(d) {
const days = ['日', '一', '二', '三', '四', '五', '六'];
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日 周${days[d.getDay()]}`;
}
// 每秒刷新一次大字时间卡(含秒显)
// Refresh the big time card every second (including the seconds display)
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('.h').textContent = fmtHour(now);
card.querySelector('.m').textContent = fmtMinute(now);
card.querySelector('.sec').textContent = fmtSec(now);
card.querySelector('.date-full').textContent = fmtFullDate(now);
}, 1000);
}
// 当前媒体会话列表(多个:音乐/听书/视频)+ 当前展示索引
// Current media session list (multiple: music/audiobook/video) + shown index
let currentMediaList = null;
let currentMediaIndex = 0;
// SVG 控制图标(fill=currentColor,替代 emoji
// SVG control icons (fill=currentColor, replacing emoji)
const ICON_PREV = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6l-8 6 8 6V6z"/></svg>';
const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7L8 5z"/></svg>';
const ICON_PAUSE = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h3v14H7zM14 5h3v14h-3z"/></svg>';
const ICON_NEXT = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M4 6l8 6-8 6V6z"/></svg>';
// 原生媒体会话事件:接收会话列表,有媒体时渲染媒体卡,无媒体时降级
// Native media session event: receives the session list; render media card when
// present, otherwise fall back to re-layout
window.HearthEvents = window.HearthEvents || {};
window.HearthEvents.mediaSessionChanged = function (infos) {
if (!infos || infos.length === 0) {
currentMediaList = null;
currentMediaIndex = 0;
mediaProgressState = null;
} else {
currentMediaList = infos;
if (currentMediaIndex >= infos.length) currentMediaIndex = 0;
updateMediaProgressState(infos[currentMediaIndex]);
}
const grid = document.getElementById('tri-col');
if (!grid) return;
// 若媒体卡已存在且还有媒体,增量更新(歌词实时变化时避免重建整个首页导致卡顿)
// If the media card exists and media is still active, update in place (avoid
// rebuilding the whole home on every live-lyrics change, which causes jank)
const card = grid.querySelector('.media-card');
if (card && currentMediaList && currentMediaList.length > 0) {
updateMediaCardInPlace(card, currentMediaList[currentMediaIndex]);
return;
}
window.updateHomeCards();
};
// 增量更新媒体卡内容(标题/艺术家/封面/播放图标),不重建整个首页
// Update the media card in place (title/artist/cover/play icon) without rebuilding
function updateMediaCardInPlace(card, info) {
const tt = card.querySelector('.tt');
if (tt) tt.textContent = info.title;
const ar = card.querySelector('.ar');
if (ar) ar.textContent = info.artist;
const cover = card.querySelector('.cover');
if (cover && info.cover) cover.src = info.cover;
const playBtn = card.querySelector('[data-act="play"]');
if (playBtn) playBtn.innerHTML = info.playing ? ICON_PAUSE : ICON_PLAY;
}
// 媒体卡:封面 + 标题/艺术家 + 进度条 + 控制按钮 + 多会话滑动切换
// Media card: cover + title/artist + progress + controls + multi-session swipe
function renderMediaCard(info) {
const el = document.createElement('div');
el.className = 'card media-card';
const coverHtml = info.cover
? `<img class="cover" src="${info.cover}" alt="">`
: '<div class="cover cover-ph"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V6l12-2v12"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></div>';
const playIcon = info.playing ? ICON_PAUSE : ICON_PLAY;
// title/artist 来自任意应用元数据,转义后插入,防 XSS
// title/artist come from arbitrary app metadata; escape before insert (XSS)
const pct = info.duration ? Math.min(100, (info.position / info.duration) * 100) : 0;
el.innerHTML = `
<div class="media-layout">
${coverHtml}
<div class="media-info">
<div class="tt">${escapeHtml(info.title)}</div>
<div class="ar">${escapeHtml(info.artist)}</div>
</div>
</div>
<div class="prog"><div class="bar" style="width:${pct}%"></div></div>
<div class="time-row">
<span class="t-cur">${fmtDuration(info.position)}</span>
<span class="t-total">${fmtDuration(info.duration)}</span>
</div>
<div class="controls">
<button class="ctl" data-act="prev" title="上一首">${ICON_PREV}</button>
<button class="ctl play" data-act="play" title="播放/暂停">${playIcon}</button>
<button class="ctl" data-act="next" title="下一首">${ICON_NEXT}</button>
</div>`;
// 绑定控制按钮(传入当前索引,作用于当前滑到的会话)
// Bind the controls (pass the current index, act on the shown session)
el.querySelector('[data-act="prev"]').onclick = () => bridge.call('mediaPrevious', currentMediaIndex);
el.querySelector('[data-act="play"]').onclick = () => bridge.call('mediaPlayPause', currentMediaIndex);
el.querySelector('[data-act="next"]').onclick = () => bridge.call('mediaNext', currentMediaIndex);
// 封面点击跳转到对应媒体 App 的播放界面
// Cover tap opens the media app's playback UI
const coverEl = el.querySelector('.cover');
if (coverEl) coverEl.onclick = () => bridge.call('openMediaApp', currentMediaIndex);
// 进度条拖动 seek
setupMediaSeek(el);
// 进度状态 + 全局 ticker
updateMediaProgressState(info);
startMediaTicker();
// 多会话滑动切换
setupMediaSwipe(el);
// 布局方向(上下 / 左右)
applyMediaLayout(el);
return el;
}
// 全局进度状态:独立于卡片重建,进度条不因重新渲染而"跳回"
// Global progress state: independent of card rebuild, so the bar doesn't reset on re-render
let mediaProgressState = null;
function updateMediaProgressState(info) {
mediaProgressState = {
position: info.position || 0,
duration: info.duration || 0,
playing: !!info.playing,
updatedAt: Date.now(),
};
}
let mediaTicker = null;
function startMediaTicker() {
if (mediaTicker) return;
const tick = () => {
if (!mediaProgressState) return;
const bar = document.querySelector('.media-card .bar');
const cur = document.querySelector('.media-card .t-cur');
const { position, duration, playing, updatedAt } = mediaProgressState;
if (!duration) {
// 无时长信息(部分 App 不提供 duration):显示活动脉冲而非空白
// No duration (some apps omit it): show an indeterminate pulse instead of blank
if (bar) bar.classList.add('indeterminate');
return;
}
const pos = playing ? position + (Date.now() - updatedAt) : position;
if (bar) {
bar.classList.remove('indeterminate');
bar.style.width = Math.min(100, (pos / duration) * 100) + '%';
}
if (cur) cur.textContent = fmtDuration(pos);
};
tick(); // 立即执行一次,避免初始 1 秒内进度条空白
mediaTicker = setInterval(tick, 1000);
}
// 时长格式化:毫秒 -> m:ss(未知返回 --:--
// Duration format: ms -> m:ss (--:-- when unknown)
function fmtDuration(ms) {
if (!ms || ms <= 0) return '--:--';
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
return m + ':' + String(s % 60).padStart(2, '0');
}
// 进度条拖动 seek:拖动预览进度,松手后 seek 到目标位置
// Progress-bar drag seek: preview while dragging, seek on release
function setupMediaSeek(el) {
const prog = el.querySelector('.prog');
if (!prog) return;
let dragging = false;
const ratioAt = (clientX) => {
const rect = prog.getBoundingClientRect();
return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
};
const preview = (clientX) => {
const bar = el.querySelector('.bar');
if (bar) bar.style.width = (ratioAt(clientX) * 100) + '%';
};
prog.addEventListener('touchstart', (e) => {
dragging = true;
preview(e.touches[0].clientX);
}, { passive: true });
prog.addEventListener('touchmove', (e) => {
if (dragging) preview(e.touches[0].clientX);
}, { passive: true });
prog.addEventListener('touchend', (e) => {
if (!dragging) return;
dragging = false;
const ratio = ratioAt(e.changedTouches[0].clientX);
const duration = mediaProgressState ? mediaProgressState.duration : 0;
if (duration > 0) {
const pos = Math.floor(ratio * duration);
bridge.call('mediaSeekTo', currentMediaIndex, pos);
if (mediaProgressState) {
mediaProgressState.position = pos;
mediaProgressState.updatedAt = Date.now();
}
}
}, { passive: true });
}
// 布局方向:默认上下(封面在上、信息在下),横条(宽>高)时左右
// Layout direction: vertical (cover on top, info below) by default; horizontal
// (cover left, info right) when the card is wider than tall
function applyMediaLayout(el) {
const check = () => {
const w = el.clientWidth;
const h = el.clientHeight;
if (w > 0 && h > 0) el.classList.toggle('wide', w > h);
};
check();
requestAnimationFrame(check);
}
// 多会话滑动切换(左右滑动切换媒体会话)
// Multi-session swipe (swipe left/right to switch media sessions)
function setupMediaSwipe(el) {
let startX = 0;
el.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
}, { passive: true });
el.addEventListener('touchend', (e) => {
const dx = e.changedTouches[0].clientX - startX;
if (Math.abs(dx) < 60) return;
switchMedia(dx < 0 ? 1 : -1);
}, { passive: true });
}
// 切换当前媒体会话
// Switch the current media session
function switchMedia(delta) {
if (!currentMediaList || currentMediaList.length <= 1) return;
currentMediaIndex = (currentMediaIndex + delta + currentMediaList.length) % currentMediaList.length;
const info = currentMediaList[currentMediaIndex];
updateMediaProgressState(info);
const grid = document.getElementById('tri-col');
if (!grid) return;
const old = grid.querySelector('.media-card');
if (!old) return;
// 滑动方向过渡:左滑(下一个)旧卡左出、新卡右入;右滑反之
// Directional transition: swipe left (next) slides old out left and new in from
// the right; swipe right (prev) reverses it
const outClass = delta > 0 ? 'slide-out-left' : 'slide-out-right';
const inClass = delta > 0 ? 'slide-in-right' : 'slide-in-left';
old.classList.add(outClass);
// 用 setTimeout 替代 animationend(更可靠,避免动画事件丢失导致"只能划一次")
// Use setTimeout instead of animationend (more reliable, avoids the "swipe once
// then stuck" issue when the animation event is lost)
setTimeout(() => {
if (!old.isConnected) return;
const fresh = renderMediaCard(info);
fresh.classList.add('span-2'); // 保持跨两栏(renderMediaCard 自身不带 span 类)
fresh.classList.add(inClass);
old.replaceWith(fresh);
}, 180);
}
// 转义 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;');
}
+282
View File
@@ -0,0 +1,282 @@
// 设置页:Miuix Preference 分组 + 玻璃条目,含主题/遮罩/亮度/权限/网络交互
// Settings page: Miuix Preference groups + glass items with interactions for
// theme / mask / brightness / permission / network
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" id="theme-item">
<span class="lbl">主题</span>
<span class="arrow" id="theme-state">跟随系统</span>
</div>
<div class="st-item" id="glass-item">
<span class="lbl">玻璃效果</span>
<span class="sw" id="glass-sw"><i></i></span>
</div>
<div class="st-item" id="brightness-item">
<span class="lbl">屏幕亮度</span>
<span class="arrow" id="brightness-state"></span>
</div>
<div class="st-group" id="mask-group" style="display:none">背景遮罩</div>
<div class="st-item" id="mask-item" style="display:none">
<span class="lbl">启用背景遮罩</span>
<span class="sw" id="mask-sw"><i></i></span>
</div>
<div class="st-item" id="mask-alpha-item" style="display:none">
<span class="lbl">遮罩明暗度</span>
<span class="slider" id="mask-slider"><i></i></span>
</div>
<div class="st-group">权限</div>
<div class="st-item" id="notif-access">
<span class="lbl">通知使用权(媒体卡)</span>
<span class="arrow" id="notif-access-state"></span>
</div>
<div class="st-group">网络</div>
<div class="st-item" id="server-url-item">
<span class="lbl">webAPP 服务器地址</span>
<span class="arrow" id="server-url-state"></span>
</div>
<div class="st-item" id="check-update-item">
<span class="lbl">检查更新</span>
<span class="arrow" id="check-update-state"></span>
</div>
<div class="st-group">关于</div>
<div class="st-item" id="export-log-item">
<span class="lbl">导出日志</span>
<span class="arrow" id="export-log-state"></span>
</div>
<div class="st-item"><span class="lbl">版本 0.1.0</span><span class="arrow"></span></div>
</div>`;
setupTheme(page);
setupGlass(page);
setupMask(page);
setupNotificationAccess(page);
setupBrightness(page);
setupServerUrl(page);
setupCheckUpdate(page);
setupExportLog(page);
};
// 判断当前是否深色主题(与 mask.js 逻辑一致)
// Whether the current theme is dark (same logic as mask.js)
function isDarkTheme() {
const t = theme.get();
if (t === 'dark') return true;
if (t === 'light') return false;
return matchMedia('(prefers-color-scheme: dark)').matches;
}
// 主题三态循环:跟随系统 → 浅色 → 深色
// Theme tri-state cycle: system → light → dark
function setupTheme(page) {
const state = page.querySelector('#theme-state');
const refresh = () => { state.textContent = theme.label(theme.get()); };
refresh();
page.querySelector('#theme-item').onclick = () => {
theme.cycle();
refresh();
window.mask.apply();
setupMask(page); // 主题切换后重算遮罩项的显示与状态
};
}
// 玻璃效果开关:普通半透明 ↔ 液态玻璃(backdrop-filter 磨砂 + 边缘高光)
// Glass effect toggle: plain translucent vs liquid glass
function setupGlass(page) {
const sw = page.querySelector('#glass-sw');
const refresh = () => sw.classList.toggle('on', glass.enabled());
refresh();
page.querySelector('#glass-item').onclick = () => {
glass.setEnabled(!glass.enabled());
refresh();
};
}
// 背景遮罩:开关 + 明暗度(仅夜间主题显示)
// Wallpaper dim: toggle + opacity (shown only under dark theme)
function setupMask(page) {
const group = page.querySelector('#mask-group');
const maskItem = page.querySelector('#mask-item');
const alphaItem = page.querySelector('#mask-alpha-item');
const sw = page.querySelector('#mask-sw');
const slider = page.querySelector('#mask-slider');
const thumb = slider.querySelector('i');
const dark = isDarkTheme();
const show = dark;
group.style.display = show ? '' : 'none';
maskItem.style.display = show ? '' : 'none';
alphaItem.style.display = show ? '' : 'none';
const refresh = () => {
sw.classList.toggle('on', window.mask.enabled());
const a = window.mask.alpha();
thumb.style.left = (a * 100) + '%';
slider.style.setProperty('--fill', (a * 100) + '%');
};
refresh();
sw.onclick = () => {
window.mask.setEnabled(!window.mask.enabled());
refresh();
};
makeSliderDraggable(slider, (a) => {
window.mask.setAlpha(Math.round(a * 100) / 100);
refresh();
});
}
// 通知使用权授权项:异步查状态,未授权点击跳转系统授权页
// Notification-access item: async state check, tap to open the system grant page
function setupNotificationAccess(page) {
const item = page.querySelector('#notif-access');
const state = page.querySelector('#notif-access-state');
bridge.call('getNotificationAccess').then(granted => {
state.textContent = granted ? '已开启 ✓' : '去开启 ';
if (granted) state.style.color = 'var(--accent)';
}).catch(() => {});
item.onclick = () => {
bridge.call('requestNotificationAccess').catch(() => {});
};
}
// 屏幕亮度:未授权「修改系统设置」时跳授权页,已授权点击滑块调整系统亮度
// Screen brightness: jump to grant page when lacking WRITE_SETTINGS; adjust via
// slider click once granted
function setupBrightness(page) {
const item = page.querySelector('#brightness-item');
const state = page.querySelector('#brightness-state');
let sliderEl = null;
const renderSlider = (value) => {
if (sliderEl) sliderEl.remove();
sliderEl = document.createElement('span');
sliderEl.className = 'slider brightness-slider';
sliderEl.innerHTML = '<i></i>';
sliderEl.style.width = '160px';
const pct = value / 255 * 100;
sliderEl.style.setProperty('--fill', pct + '%');
sliderEl.querySelector('i').style.left = pct + '%';
makeSliderDraggable(sliderEl, (ratio) => {
const v = Math.round(ratio * 255);
bridge.call('setSystemBrightness', v).then(() => renderSlider(v)).catch(() => {});
});
item.appendChild(sliderEl);
};
const refresh = () => {
bridge.call('canWriteSettings').then(canWrite => {
if (!canWrite) {
state.textContent = '去授权 ';
if (sliderEl) { sliderEl.remove(); sliderEl = null; }
} else {
bridge.call('getSystemBrightness').then(v => {
state.textContent = '';
renderSlider(v);
}).catch(() => {});
}
}).catch(() => {});
};
refresh();
item.onclick = () => {
bridge.call('canWriteSettings').then(canWrite => {
if (!canWrite) bridge.call('requestWriteSettings');
}).catch(() => {});
};
}
// webAPP 服务器地址:点击展开输入框,保存后重新拉取清单
// webAPP server URL: expand an input on tap, re-fetch manifest after saving
function setupServerUrl(page) {
const item = page.querySelector('#server-url-item');
const state = page.querySelector('#server-url-state');
let editing = false;
const startEdit = (current) => {
editing = true;
item.innerHTML = '<input class="url-input" id="server-url-input" value="' + (current || '').replace(/"/g, '&quot;') + '">' +
'<button class="url-save" id="server-url-save">确认</button>';
const input = item.querySelector('#server-url-input');
const saveBtn = item.querySelector('#server-url-save');
input.focus();
const save = () => {
const v = input.value.trim();
if (!v) return;
bridge.call('setServerUrl', v).then(() => {
editing = false;
refresh();
}).catch(() => {});
};
saveBtn.onclick = save;
input.onkeydown = (e) => { if (e.key === 'Enter') save(); };
};
const refresh = () => {
bridge.call('getServerUrl').then(url => {
state.textContent = (url || '未设置') + ' ';
}).catch(() => {});
};
refresh();
item.onclick = () => {
if (editing) return;
bridge.call('getServerUrl').then(startEdit).catch(() => startEdit(''));
};
}
// 检查更新:触发重新拉取 webAPP 清单 + 卡片目录,完成后提示
// Check update: re-fetch the webAPP manifest + card catalog, then show a hint
function setupCheckUpdate(page) {
const item = page.querySelector('#check-update-item');
const state = page.querySelector('#check-update-state');
item.onclick = () => {
state.textContent = '检查中…';
bridge.call('refreshManifest').then(result => {
let r = { added: 0, removed: 0, changed: 0 };
try { r = JSON.parse(result); } catch (e) {}
const parts = [];
if (r.added > 0) parts.push('新增 ' + r.added);
if (r.removed > 0) parts.push('移除 ' + r.removed);
if (r.changed > 0) parts.push('更新 ' + r.changed);
state.textContent = parts.length ? parts.join('') + ' ' : '无更新 ';
}).catch(() => {
state.textContent = '失败 ';
});
};
}
// 导出日志:点击导出到 Download,显示结果
// Export log: tap to export to Download, show the result
function setupExportLog(page) {
const item = page.querySelector('#export-log-item');
const state = page.querySelector('#export-log-state');
item.onclick = () => {
state.textContent = '导出中…';
bridge.call('exportLog').then(result => {
state.textContent = result + ' ';
}).catch(() => {
state.textContent = '失败 ';
});
};
}
// 通用滑块拖动:touch 拖动实时回调(保留 click 兼容桌面调试)
// Generic draggable slider: touch-drag with live callback (click kept for desktop)
function makeSliderDraggable(slider, onRatio) {
let dragging = false;
const ratioAt = (clientX) => {
const rect = slider.getBoundingClientRect();
return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
};
slider.addEventListener('touchstart', (e) => {
dragging = true;
onRatio(ratioAt(e.touches[0].clientX));
}, { passive: true });
slider.addEventListener('touchmove', (e) => {
if (dragging) onRatio(ratioAt(e.touches[0].clientX));
}, { passive: true });
slider.addEventListener('touchend', () => { dragging = false; }, { passive: true });
slider.addEventListener('click', (e) => onRatio(ratioAt(e.clientX)));
}
@@ -0,0 +1,206 @@
// H5 应用列表页:富卡片 + 在线/离线/可更新标签 + 搜索 + 长按菜单(更新/删除/导入/属性)
// H5 web app list page: rich cards + online/offline/updatable badge + search +
// long-press menu (update/delete/import/properties)
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>`;
// 优先用预加载缓存,未就绪则实时加载
// Prefer the preloaded cache; fetch live when not ready yet
const apps = window.preload.webApps || JSON.parse(await bridge.call('fetchWebApps'));
const grid = document.getElementById('web-grid');
const render = (list) => {
grid.textContent = '';
list.forEach(a => {
const btn = document.createElement('button');
btn.className = 'wcell';
btn.dataset.id = a.id;
btn.dataset.url = a.url;
const img = document.createElement('img');
img.className = 'ic';
img.src = a.icon;
img.alt = '';
const lbl = document.createElement('span');
lbl.className = 'lbl';
lbl.textContent = a.name;
const tag = document.createElement('span');
tag.className = 'tag';
tag.textContent = a.offline ? '离线' : '在线';
btn.append(img, lbl, tag);
// 云端有离线包 → 加下载图标(默认隐藏,异步按本地状态决定是否显示)
// Cloud has an offline package -> add a download icon (hidden by default,
// shown based on local state asynchronously)
let dl = null;
if (a.offline) {
dl = document.createElement('span');
dl.className = 'wcell-dl';
dl.title = '下载离线包';
dl.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>';
dl.style.display = 'none';
dl.onclick = (e) => {
e.stopPropagation();
bridge.call('updateOfflinePackage', a.id).then(() => refreshList()).catch(() => {});
};
btn.appendChild(dl);
}
btn.onclick = () => bridge.call('openWebApp', btn.dataset.id);
setupLongPress(btn, a);
grid.appendChild(btn);
// 异步检查本地离线包状态 + 版本对比,更新标签
refreshBadge(tag, a, dl);
});
};
render(apps);
document.getElementById('web-search').oninput = (e) => {
const kw = e.target.value.toLowerCase();
render(apps.filter(a => a.name.toLowerCase().includes(kw)));
};
};
// 异步刷新列表项标签(离线 / 可更新)+ 下载图标显隐
// Refresh the badge asynchronously (offline / updatable) + download icon visibility
async function refreshBadge(tag, app, dl) {
try {
const hasLocal = await bridge.call('hasLocalPackage', app.id);
const localVer = await bridge.call('getLocalVersion', app.id);
const cloudVer = app.offlineVersion || '';
const updatable = hasLocal && cloudVer && cloudVer !== localVer;
// 下载图标仅在「未安装且有离线包」或「有可更新版本」时显示
// Download icon shows only when there's no local package but cloud has one,
// or when a newer version is available
const needDownload = app.offline && (!hasLocal || updatable);
if (updatable) {
tag.textContent = '可更新';
} else if (hasLocal) {
tag.textContent = '离线';
} else if (app.offline) {
tag.textContent = '离线';
} else {
tag.textContent = '在线';
}
if (dl) dl.style.display = needDownload ? '' : 'none';
} catch (e) { /* 忽略 */ }
}
// 长按(600ms)弹出管理菜单
// Long-press (600ms) to show the management menu
function setupLongPress(btn, app) {
let timer = null;
const start = (e) => {
timer = setTimeout(() => {
showWebAppMenu(app, e.touches[0].clientX, e.touches[0].clientY);
}, 600);
};
const cancel = () => { if (timer) clearTimeout(timer); };
btn.addEventListener('touchstart', start, { passive: true });
btn.addEventListener('touchend', cancel, { passive: true });
btn.addEventListener('touchmove', cancel, { passive: true });
}
// 弹出管理菜单:更新 / 删除 / 导入 / 属性
// Show the management menu: update / delete / import / properties
async function showWebAppMenu(app, x, y) {
document.querySelectorAll('.wapp-menu').forEach(m => m.remove());
const hasLocal = await bridge.call('hasLocalPackage', app.id);
const localVer = await bridge.call('getLocalVersion', app.id);
const cloudVer = app.offlineVersion || '';
const menu = document.createElement('div');
menu.className = 'wapp-menu';
const addItem = (label, onClick) => {
const item = document.createElement('div');
item.className = 'wapp-menu-item';
item.textContent = label;
item.onclick = () => { menu.remove(); if (onClick) onClick(); };
menu.appendChild(item);
};
// 更新(本地有包 + 云端版本不同)
if (hasLocal && cloudVer && cloudVer !== localVer) {
addItem('更新离线包(' + (localVer || '?') + ' → ' + cloudVer + '', () => {
bridge.call('updateOfflinePackage', app.id).catch(() => {});
});
}
// 重装(本地有包 + 云端有离线包:强制删除重下,无论版本)
// Reinstall (local + cloud offline package: force delete and re-download, any version)
if (hasLocal && app.offline) {
addItem('重装离线包', () => {
bridge.call('updateOfflinePackage', app.id).then(() => refreshList()).catch(() => {});
});
}
// 删除(本地有包)
if (hasLocal) {
addItem('删除本地包', () => {
bridge.call('deleteLocalPackage', app.id).then(() => refreshList()).catch(() => {});
});
}
// 安装到本地(本地无包 + 云端有离线包:预下载到本地,不打开)
// Install locally (no local package + cloud offline package: pre-download without opening)
if (!hasLocal && app.offline) {
addItem('安装到本地', () => {
bridge.call('updateOfflinePackage', app.id).then(() => refreshList()).catch(() => {});
});
}
// 导入
addItem('导入离线包', () => {
bridge.call('importOfflinePackage', app.id).catch(() => {});
});
// 属性
addItem('属性:本地 ' + (localVer || '无') + ' / 云端 ' + (cloudVer || '无'), null);
menu.style.left = Math.min(x, window.innerWidth - 220) + 'px';
menu.style.top = Math.min(y, window.innerHeight - 180) + 'px';
document.body.appendChild(menu);
}
// 刷新列表(删除/导入后重新加载)
// Refresh the list (reload after delete/import)
function refreshList() {
const page = document.querySelector('[data-page="webapplist"]');
if (page) {
page.dataset.loaded = '';
if (page.classList.contains('active')) window.loadWebAppList();
}
}
// webapp 加载/下载进度:显示覆盖层
// webapp load/download progress: show the overlay
window.HearthEvents = window.HearthEvents || {};
window.HearthEvents.webappProgress = function (id, progress) {
const loading = document.getElementById('webapp-loading');
if (!loading) return;
if (progress >= 100) {
loading.classList.remove('show');
return;
}
loading.classList.add('show');
loading.querySelector('.wl-fill').style.width = progress + '%';
};
// 导入完成:刷新列表
// Import complete: refresh the list
window.HearthEvents.webappImported = function (id, ok) {
if (!ok) return;
refreshList();
};
// 检查更新后刷新列表:清 preload 缓存并重渲染
// Refresh the list after check-update: clear the preload cache and re-render
window.HearthEvents.webappUpdated = function () {
if (window.preload) window.preload.webApps = null;
const page = document.querySelector('[data-page="webapplist"]');
if (page) {
page.dataset.loaded = '';
if (page.classList.contains('active')) window.loadWebAppList();
}
};
// 点击菜单外部关闭长按菜单(touchstart 更及时,移动端可靠)
// Close the long-press menu when tapping outside (touchstart fires promptly on mobile)
document.addEventListener('touchstart', (e) => {
document.querySelectorAll('.wapp-menu').forEach((m) => {
if (!m.contains(e.target)) m.remove();
});
}, { passive: true });
+24
View File
@@ -0,0 +1,24 @@
// 页面数据预加载:启动后后台拉取各页面数据缓存,切页时直接渲染减少延迟
// Page data preload: fetch and cache page data in the background after startup,
// so page switches render immediately without a fetch delay
window.preload = {
apps: null,
webApps: null,
cards: null,
async loadAll() {
try {
const [apps, webApps, cards] = await Promise.all([
bridge.call('listApps').catch(() => null),
bridge.call('fetchWebApps').catch(() => null),
bridge.call('fetchCards').catch(() => null),
]);
this.apps = apps ? JSON.parse(apps) : null;
this.webApps = webApps ? JSON.parse(webApps) : null;
this.cards = cards ? JSON.parse(cards) : null;
} catch (e) {
// 预加载失败不阻塞:切页时仍会实时加载
// Preload failure is non-blocking: page switches still fetch live
}
},
};
+59
View File
@@ -0,0 +1,59 @@
// 桌面路由:侧边栏 5 页 + 页面切换 + 顶部时间
// Launcher router: 5 sidebar pages + page switching + top clock
// 侧边栏图标:内联 SVGstroke=currentColor,跟随主题色),替代 emoji
// Sidebar icons: inline SVG (stroke=currentColor, follows theme color), replacing emoji
const SVG_ICONS = {
home: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8"/><path d="M5 9.5V21h14V9.5"/><path d="M10 21v-6h4v6"/></svg>',
immersive: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 11l1.5-4.5A2 2 0 018.4 5h7.2a2 2 0 011.9 1.5L19 11"/><path d="M3 11v5a1 1 0 001 1h1a1 1 0 001-1v-1h12v1a1 1 0 001 1h1a1 1 0 001-1v-5"/><circle cx="7.5" cy="16" r="1.6"/><circle cx="16.5" cy="16" r="1.6"/></svg>',
webapp: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 010 18a15 15 0 010-18"/></svg>',
app: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="8" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/><rect x="13" y="13" width="8" height="8" rx="2"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33a1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51a1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>',
};
const PAGES = [
{ id: 'home', label: '首页', icon: SVG_ICONS.home },
{ id: 'immersive', label: '沉浸首页', icon: SVG_ICONS.immersive },
{ id: 'webapplist', label: 'H5 应用', icon: SVG_ICONS.webapp },
{ id: 'applist', label: '安卓 APP', icon: SVG_ICONS.app },
{ id: 'settings', label: '设置', icon: SVG_ICONS.settings },
];
const router = {
current: 'home',
navigate(id) {
this.current = id;
document.querySelectorAll('[data-page]').forEach(el =>
el.classList.toggle('active', el.dataset.page === id));
// 切页时把前台 webapp 丢到后台(隐藏内容 WebView,标签状态保留)
// Hide the foreground webapp when switching pages (keep tab state)
bridge.call('hideWebApps').catch(() => {});
// 同时隐藏 webapp 顶栏与标签面板(离开 webapp 场景)
// Also hide the webapp topbar and tab panel (leaving the webapp context)
const topbar = document.getElementById('web-topbar');
if (topbar) topbar.style.display = 'none';
const tabPanel = document.getElementById('tab-panel');
if (tabPanel) tabPanel.style.display = 'none';
// 移除 webapp-activewebapp 已丢后台,恢复 webapp 列表显示
// Remove webapp-active: the webapp is backgrounded, restore the list display
document.body.classList.remove('webapp-active');
// 关闭长按菜单(切页时)
// Close the long-press menu on page switch
document.querySelectorAll('.wapp-menu').forEach((m) => m.remove());
// 非首页时侧边栏顶部显示小时间(容器始终占位,仅切换可见性避免撑动图标)
// Show the small clock at the sidebar top when not on home (the container
// always occupies space; only toggle visibility to avoid shifting icons)
const clock = document.getElementById('rail-clock');
if (clock) clock.style.visibility = (id === 'home') ? 'hidden' : 'visible';
// 延迟内容加载到下一帧:让切换动画先流畅播放,避免加载重活(查应用/拉清单)
// 在动画帧内阻塞导致卡顿
// Defer content loading to the next frame: let the switch animation play
// smoothly first, avoiding jank from heavy loads (app query / manifest fetch)
// blocking within the animation frame
requestAnimationFrame(() => {
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();
});
}
};
+37
View File
@@ -0,0 +1,37 @@
// 主题三态管理:跟随系统 / 浅色 / 深色,存 localStorage,通过 data-theme 覆盖 CSS token
// Theme tri-state management: system / light / dark, persisted in localStorage,
// overriding CSS tokens via the data-theme attribute
(function () {
const KEY = 'hearth-theme';
const ORDER = ['system', 'light', 'dark'];
const LABELS = { system: '跟随系统', light: '浅色', dark: '深色' };
window.theme = {
// 当前主题(默认跟随系统)
get() {
return localStorage.getItem(KEY) || 'system';
},
// 应用主题:写入 data-theme 属性(system 时移除)
apply(t) {
localStorage.setItem(KEY, t);
const root = document.documentElement;
if (t === 'light') root.setAttribute('data-theme', 'light');
else if (t === 'dark') root.setAttribute('data-theme', 'dark');
else root.removeAttribute('data-theme');
},
// 三态循环,返回新主题
cycle() {
const cur = this.get();
const next = ORDER[(ORDER.indexOf(cur) + 1) % ORDER.length];
this.apply(next);
return next;
},
// 主题显示名
label(t) {
return LABELS[t] || t;
},
};
// 启动即应用已存主题
window.theme.apply(window.theme.get());
})();
+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,86 @@
// 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');
}
// 标签按钮:调原生弹出标签面板(PopupWindow 在内容 WebView 之上)
// Tab button: show the native tab panel (PopupWindow above the content WebView)
document.getElementById('tb-tabs').onclick = () => bridge.call('showTabPanel');
document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`;
const active = tabs.find(t => t.active);
document.getElementById('tb-title').textContent = active ? active.name : '';
// 无标签时隐藏顶栏与标签面板(回到桌面)
// Hide the topbar and tab panel when no tabs remain (back to desktop)
bar.style.display = tabs.length ? 'flex' : 'none';
const panel = document.getElementById('tab-panel');
if (panel && !tabs.length) panel.style.display = 'none';
// 有 webapp 时隐藏 webapplist 页面内容(避免半透明顶栏透出列表控件),无 webapp 时恢复
// Hide the webapp list content while a webapp is open (so the translucent topbar
// doesn't show the list controls through it); restore when none are open
document.body.classList.toggle('webapp-active', tabs.length > 0);
};
// 标签面板:列出所有标签,点击切换,× 关闭
// 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);
});
// 横向展开(CSS flex-direction: row
// Expand horizontally (CSS flex-direction: row)
panel.style.display = 'flex';
}
// 收起标签面板
// Collapse the tab panel
function closeTabPanel() {
const panel = document.getElementById('tab-panel');
if (panel) panel.style.display = 'none';
}
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>示例应用</title>
<style>
/* Miuix 风格示例页面 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "MiSans", "PingFang SC", sans-serif;
background: #f5f5f7;
color: #1a1a1a;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
}
.icon {
width: 72px; height: 72px; border-radius: 20px;
background: linear-gradient(135deg, #ff6900, #ff9d00);
display: flex; align-items: center; justify-content: center;
font-size: 34px; color: #fff;
}
h1 { font-size: 24px; font-weight: 600; }
.card {
background: #fff; border-radius: 16px; padding: 16px 20px;
width: 100%; max-width: 360px;
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
.row { display: flex; justify-content: space-between; padding: 8px 0; font-size: 14px; }
.row .k { color: #8a8a90; }
.note { font-size: 12px; color: #8a8a90; text-align: center; line-height: 1.6; }
</style>
</head>
<body>
<div class="icon">🎨</div>
<h1>示例应用</h1>
<div class="card">
<div class="row"><span class="k">来源</span><span>Hearth 内置</span></div>
<div class="row"><span class="k">当前时间</span><span id="clock">--:--:--</span></div>
</div>
<p class="note">这是打包在 Hearth APK 内的示例 webapp<br>用于演示 webAPP 打开 / 顶栏导航 / 多标签</p>
<script>
function tick() {
var now = new Date();
var p = function (n) { return String(n).padStart(2, '0'); };
document.getElementById('clock').textContent =
p(now.getHours()) + ':' + p(now.getMinutes()) + ':' + p(now.getSeconds());
}
tick();
setInterval(tick, 1000);
</script>
</body>
</html>
+523 -12
View File
@@ -1,43 +1,253 @@
package top.yeij.hearth
import android.app.Activity
import android.app.WallpaperManager
import android.content.ContentValues
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Bundle
import android.provider.MediaStore
import android.provider.Settings
import android.text.TextUtils
import android.util.Base64
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
import androidx.core.app.NotificationManagerCompat
import com.google.gson.Gson
import top.yeij.hearth.app.AndroidAppSource
import top.yeij.hearth.app.AppRepository
import top.yeij.hearth.cache.CacheManager
import top.yeij.hearth.card.CardRepository
import top.yeij.hearth.media.MediaSessionSource
import top.yeij.hearth.webapp.OkHttpHttpClient
import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webapp.WebAppStorage
import top.yeij.hearth.webview.BrightnessProvider
import top.yeij.hearth.webview.JsBridge
import top.yeij.hearth.webview.NotificationAccessProvider
import top.yeij.hearth.webview.ServerUrlProvider
import top.yeij.hearth.webview.WallpaperProvider
import top.yeij.hearth.webview.WebAppHost
import top.yeij.hearth.webview.WebViewManager
import java.io.ByteArrayOutputStream
import java.io.File
// HOME 桌面 activity后续 Task 挂载 WebView 与 JsBridge
// HOME launcher activity; later tasks mount WebView and JsBridge
// HOME 桌面 activity接线各能力层:Repository、媒体监听、多 WebView webAPP 管理
// HOME launcher activity, wiring all capability layers: repositories, media listener,
// and multi-WebView webapp management
class MainActivity : Activity() {
companion object {
private const val TAG = "HearthMainActivity"
// 占位清单/目录地址,真机部署时可替换为实际服务器
// Placeholder manifest/catalog URLs; replace with the real server on device
private const val MANIFEST_URL = "https://example.com/hearth/manifest.json"
private const val CATALOG_URL = "https://example.com/hearth/catalog.json"
// 内容 WebView 顶部预留高度(dp):默认只留顶栏(约 54dp);标签面板弹出时
// 通过 setContentTopMargin 临时增大,收起后恢复,避免一直压缩 webapp 高度
// Content WebView top reserve (dp): default only the topbar (~54dp); the tab
// panel temporarily grows it via setContentTopMargin and restores on collapse,
// avoiding permanently shrinking the webapp height
private const val TOPBAR_HEIGHT_DP = 56
// 全局侧边栏收起宽度(dp),内容 WebView 左侧预留,避免挡住侧边栏
// Global sidebar collapsed width (dp); content WebViews reserve it on the
// left to avoid covering the sidebar
private const val SIDEBAR_WIDTH_DP = 80
// SAF 导入离线包的 requestCode
private const val REQUEST_IMPORT_ZIP = 1001
}
private val webAppContainer = WebAppContainer()
private val webAppStorage = WebAppStorage(this)
// 待导入离线包的 webapp idSAF 回调用)
// Pending webapp id for offline-package import (used in the SAF callback)
private var pendingImportId: String? = null
private val gson = Gson()
private val webAppHost = WebAppHostImpl()
// 通知使用权访问实现:检查授权状态 + 跳转系统授权页(供设置页授权项使用)
// Notification-access implementation: check grant state + jump to system settings
// (used by the settings permission item)
private val notificationAccess = object : NotificationAccessProvider {
override fun isGranted(): Boolean =
NotificationManagerCompat.getEnabledListenerPackages(this@MainActivity)
.contains(packageName)
override fun requestAccess() {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
}
}
// 系统亮度访问实现:读/写 Settings.System.SCREEN_BRIGHTNESS,需 WRITE_SETTINGS 授权
// System brightness implementation: read/write Settings.System.SCREEN_BRIGHTNESS,
// requiring the WRITE_SETTINGS grant
private val brightnessProvider = object : BrightnessProvider {
override fun getSystemBrightness(): Int =
Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128)
override fun setSystemBrightness(value: Int) {
Settings.System.putInt(
contentResolver,
Settings.System.SCREEN_BRIGHTNESS,
value.coerceIn(0, 255)
)
}
override fun canWriteSettings(): Boolean = Settings.System.canWrite(this@MainActivity)
override fun requestWriteSettings() {
startActivity(
Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS, Uri.parse("package:$packageName"))
)
}
}
// 服务器地址存储实现:SharedPreferences 持久化,设置时同步更新 WebAppRepository
// Server URL storage implementation: persisted in SharedPreferences, syncing the
// WebAppRepository manifest URL on change
private val serverUrlProvider = object : ServerUrlProvider {
private val prefs by lazy { getSharedPreferences("hearth", MODE_PRIVATE) }
override fun getServerUrl(): String =
prefs.getString("server_url", MANIFEST_URL) ?: MANIFEST_URL
override fun setServerUrl(url: String) {
prefs.edit().putString("server_url", url).apply()
if (::webAppRepository.isInitialized) webAppRepository.setServerUrl(url)
}
}
// 壁纸提供实现:WallpaperManager 取当前壁纸转 base64,供 H5 body 背景使用
// Wallpaper implementation: WallpaperManager -> base64 for the H5 body background
private val wallpaperProvider = object : WallpaperProvider {
override fun getWallpaperBase64(): String {
return try {
val wm = WallpaperManager.getInstance(this@MainActivity)
// 动态壁纸(Live Wallpaper)是实时渲染、无静态 Drawable,返回空让 H5 保持
// 透明透出动态壁纸;此时玻璃模糊降级为普通半透明(backdrop-filter 无内容可模糊)
// Live wallpapers render in real time with no static drawable; return empty
// so H5 stays transparent and shows the live wallpaper, with the glass blur
// degrading to plain translucency (backdrop-filter has nothing to blur)
if (wm.wallpaperInfo != null) return ""
val d = wm.drawable ?: return ""
val dm = resources.displayMetrics
val bmp = Bitmap.createBitmap(dm.widthPixels, dm.heightPixels, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, dm.widthPixels, dm.heightPixels)
d.draw(canvas)
val out = ByteArrayOutputStream()
bmp.compress(Bitmap.CompressFormat.JPEG, 80, out)
bmp.recycle()
"data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} catch (e: Exception) {
Log.w(TAG, "getWallpaper: ${e.message}")
""
}
}
}
private lateinit var webAppRepository: WebAppRepository
private lateinit var mediaSource: MediaSessionSource
private lateinit var root: FrameLayout
private lateinit var desktopWebView: WebView
private lateinit var bridge: JsBridge
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate: enter immersive mode")
enterImmersive()
Log.d(TAG, "onCreate: setup webview then enter immersive mode")
// 先 setContentView 创建 DecorView,再进沉浸式;否则 window.insetsController 会
// 因 DecorView 尚未创建(null)而 NPE 崩溃
// Set content view first to create the DecorView, then enter immersive mode;
// otherwise window.insetsController throws NPE because DecorView is not created yet
setupWebView()
enterImmersive()
checkNotificationAccess()
}
// 创建 JsBridge 并挂载 WebView 桌面层
// Create JsBridge and mount the WebView launcher layer
override fun onDestroy() {
mediaSource.stop()
webAppHost.destroyAll()
super.onDestroy()
}
// 创建 JsBridge 并挂载桌面 WebView(底层),接线所有 Repository 与媒体监听
// Create JsBridge and mount the desktop WebView (bottom layer), wiring all
// repositories and the media session listener
private fun setupWebView() {
val dm = resources.displayMetrics
val darkMode =
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES
val bridge = JsBridge(
val cache = CacheManager(File(filesDir, "cache"))
val http = OkHttpHttpClient()
webAppRepository = WebAppRepository(http, cache, serverUrlProvider.getServerUrl())
bridge = JsBridge(
deviceWidthPx = dm.widthPixels,
deviceHeightPx = dm.heightPixels,
density = dm.density,
darkMode = darkMode,
appRepository = AppRepository(AndroidAppSource(this)),
webAppRepository = webAppRepository,
cardRepository = CardRepository(http, cache, CATALOG_URL),
webAppContainer = webAppContainer,
webAppHost = webAppHost,
postToMainThread = { desktopWebView.post(it) },
notificationAccess = notificationAccess,
brightness = brightnessProvider,
serverUrl = serverUrlProvider,
wallpaper = wallpaperProvider,
onShowTabPanel = { showNativeTabPanel() },
storage = webAppStorage,
onImportOfflinePackage = { id -> startImportOfflinePackage(id) },
onExportLog = { exportLog() },
)
WebViewManager(this).attach(bridge)
root = FrameLayout(this)
setContentView(root)
desktopWebView = WebViewManager(this).attach(bridge, root)
desktopWebView.webViewClient = object : WebViewClient() {
// 主帧加载完成时打日志,便于定位黑屏(判断 index.html 是否成功加载)
// Log when the main frame finishes loading, to help diagnose a black screen
override fun onPageFinished(view: WebView?, url: String?) {
Log.d(TAG, "onPageFinished: url=$url")
// H5 就绪后重新拉取一次媒体,修复启动时推送早于页面渲染的时序问题
// Re-pull media once H5 is ready: fixes the startup timing where the
// media push happens before the page has rendered
bridge.refreshMedia()
}
// 主帧加载失败时展示错误页,避免白屏
// Show an error page on main-frame load failure to avoid a blank screen
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onReceivedError(
view: WebView?,
code: Int,
description: String?,
failingUrl: String?,
) {
Log.d(TAG, "onReceivedError: code=$code desc=$description url=$failingUrl")
view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null)
}
}
mediaSource = MediaSessionSource(this)
bridge.setMediaListener(mediaSource, desktopWebView)
Log.d(
TAG,
"setupWebView: attached WebView ${dm.widthPixels}x${dm.heightPixels}" +
@@ -45,6 +255,299 @@ class MainActivity : Activity() {
)
}
// webAPP 内容 WebView 宿主实现:管理 id → WebView 映射、可见性、导航、顶栏同步
// Webapp content WebView host implementation: manage id -> WebView mapping,
// visibility, navigation, and topbar sync
private inner class WebAppHostImpl : WebAppHost {
private val contentWebViews = mutableMapOf<String, WebView>()
private var currentVisibleId: String? = null
private val topBarHeightPx by lazy {
(TOPBAR_HEIGHT_DP * resources.displayMetrics.density).toInt()
}
private val sidebarWidthPx by lazy {
(SIDEBAR_WIDTH_DP * resources.displayMetrics.density).toInt()
}
// 加载失败的 webapp idBack 键时直接关闭而非 goBack 循环回退
// Failed webapp ids: on Back, close directly instead of looping goBack
private val failedWebViews = mutableSetOf<String>()
override fun openWebView(id: String, url: String, ua: String?, scale: Int?) {
if (contentWebViews.containsKey(id)) {
switchWebView(id)
return
}
val webView = WebViewManager(this@MainActivity).createContentWebView(ua, scale)
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
Log.d(TAG, "content onPageFinished: url=$url scale=$scale")
// 缩放:CSS zoom(会 reflow,内容填满不留白),对任何页面可靠生效
// Zoom: CSS zoom (reflows, no blank space), reliable on any page
if (scale != null && scale in 1..100) {
val zoom = scale / 100f
view?.evaluateJavascript(
"(function(){var z='$zoom';var s=document.createElement('style');s.innerHTML='html{zoom:'+z+'}';document.head.appendChild(s);})();",
null
)
}
}
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onReceivedError(
view: WebView?,
code: Int,
description: String?,
failingUrl: String?,
) {
Log.d(TAG, "content onReceivedError: code=$code desc=$description url=$failingUrl")
failedWebViews.add(id)
view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null)
}
}
webView.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
// 推送 webapp 页面加载进度给 H50-100
// Push the webapp page load progress to H5 (0-100)
desktopWebView.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappProgress('$id', $newProgress);",
null
)
}
}
val params = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
params.topMargin = topBarHeightPx
params.leftMargin = sidebarWidthPx
contentWebViews[id] = webView
root.addView(webView, params)
webView.loadUrl(url)
switchWebView(id)
Log.d(TAG, "openWebView: id=$id url=$url topMargin=$topBarHeightPx")
}
override fun closeWebView(id: String) {
val webView = contentWebViews.remove(id) ?: return
failedWebViews.remove(id)
root.removeView(webView)
webView.stopLoading()
webView.destroy()
if (currentVisibleId == id) currentVisibleId = null
Log.d(TAG, "closeWebView: id=$id remaining=${contentWebViews.size}")
}
override fun switchWebView(id: String) {
currentVisibleId = id
contentWebViews.forEach { (tabId, webView) ->
webView.visibility = if (tabId == id) View.VISIBLE else View.GONE
}
Log.d(TAG, "switchWebView: id=$id")
}
override fun hideAll() {
contentWebViews.values.forEach { it.visibility = View.GONE }
currentVisibleId = null
Log.d(TAG, "hideAll: hidden ${contentWebViews.size} webviews")
}
override fun goBack(): Boolean {
val id = currentVisibleId ?: return false
// 加载失败的 webappBack 直接关闭(返回 false 让 onBackPressed 关闭标签),
// 避免 goBack 回退到失败的 file:// 页面反复加载
if (failedWebViews.contains(id)) return false
val webView = contentWebViews[id] ?: return false
return if (webView.canGoBack()) {
webView.goBack()
true
} else {
false
}
}
override fun goForward(): Boolean {
val webView = currentVisibleId?.let { contentWebViews[it] }
return if (webView != null && webView.canGoForward()) {
webView.goForward()
true
} else {
false
}
}
override fun reload() {
currentVisibleId?.let { contentWebViews[it] }?.reload()
}
override fun syncTabs(tabs: List<Tab>) {
// JsBridge 已把该调用 marshal 到主线程,这里可直接 evaluateJavascript
// JsBridge already marshaled this call to the main thread, so evaluateJavascript is safe
val json = gson.toJson(
tabs.map { mapOf("id" to it.id, "name" to it.name, "active" to it.active) }
)
desktopWebView.evaluateJavascript(
"window.showTopbar && window.showTopbar($json);",
null
)
Log.d(TAG, "syncTabs: ${tabs.size} tabs")
}
// 销毁全部内容 WebViewActivity 退出时释放资源)
// Destroy all content WebViews (release resources on activity teardown)
fun destroyAll() {
contentWebViews.values.forEach { it.stopLoading(); it.destroy() }
contentWebViews.clear()
currentVisibleId = null
}
}
// 弹出原生标签面板(PopupWindow,在内容 WebView 之上,不占用预留高度)
// Show the native tab panel (PopupWindow above the content WebView, no reserved height)
private fun showNativeTabPanel() {
val tabs = webAppContainer.tabs()
if (tabs.isEmpty()) return
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(8), dp(8), dp(8), dp(8))
background = GradientDrawable().apply {
setColor(0xE6202024.toInt())
cornerRadius = dp(16).toFloat()
}
}
val popup = PopupWindow(container, dp(220), ViewGroup.LayoutParams.WRAP_CONTENT, true)
popup.isOutsideTouchable = true
tabs.forEach { tab ->
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(12), dp(8), dp(12), dp(8))
if (tab.active) background = GradientDrawable().apply {
setColor(Color.parseColor("#ff6900"))
cornerRadius = dp(10).toFloat()
}
}
val name = TextView(this).apply {
text = tab.name
textSize = 14f
setTextColor(Color.WHITE)
isSingleLine = true
ellipsize = TextUtils.TruncateAt.END
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val close = TextView(this).apply {
text = ""
textSize = 14f
setTextColor(Color.WHITE)
setPadding(dp(10), 0, 0, 0)
setOnClickListener {
bridge.closeWebApp(tab.id)
popup.dismiss()
}
}
row.addView(name)
row.addView(close)
row.setOnClickListener {
bridge.switchTab(tab.id)
popup.dismiss()
}
container.addView(row)
}
popup.showAtLocation(root, Gravity.TOP or Gravity.END, dp(16), dp(64))
}
// dp 转 px
// dp to px
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
// 导出日志到 Download 目录(读 logcat 本进程 + 过滤 Hearth 相关行)
// Export the log to the Download directory (read logcat, filter Hearth lines)
private fun exportLog(): String {
return try {
val process = Runtime.getRuntime().exec(arrayOf("logcat", "-d"))
val log = process.inputStream.bufferedReader().readText()
val filtered = log.lines()
.filter { it.contains("Hearth") }
.joinToString("\n")
val name = "hearth-log-" + System.currentTimeMillis() + ".txt"
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
put(MediaStore.Downloads.MIME_TYPE, "text/plain")
put(MediaStore.Downloads.RELATIVE_PATH, "Download/")
}
val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
?: return "导出失败:无法创建文件"
contentResolver.openOutputStream(uri)?.use { out ->
out.write(filtered.toByteArray())
} ?: return "导出失败:无法写入"
Log.d(TAG, "exportLog: $name (${filtered.length} chars)")
"已导出:Download/$name"
} catch (e: Exception) {
Log.e(TAG, "exportLog failed: ${e.message}")
"导出失败:${e.message}"
}
}
// 触发 SAF 选择 zip 导入离线包
// Trigger SAF to pick a zip for offline-package import
private fun startImportOfflinePackage(id: String) {
pendingImportId = id
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/zip"
}
startActivityForResult(intent, REQUEST_IMPORT_ZIP)
}
// SAF 结果:导入离线包,成功后通知 H5(下次点开用本地)
// SAF result: import the offline package, notify H5 on success (next open uses local)
@Deprecated("Deprecated in Java")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_IMPORT_ZIP && resultCode == Activity.RESULT_OK) {
val id = pendingImportId ?: return
val uri = data?.data ?: return
val ok = webAppStorage.importPackage(id, uri)
Log.d(TAG, "importPackage: id=$id ok=$ok")
desktopWebView.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappImported('$id', $ok);",
null
)
}
pendingImportId = null
}
// 检测通知使用权是否已授予,未授予时打日志提示(UI 引导入口留后续)
// Check whether notification access is granted; log a hint when not
// (the settings UI entry is deferred to a later task)
private fun checkNotificationAccess() {
val granted = NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
Log.d(TAG, "checkNotificationAccess: notification listener enabled=$granted")
if (!granted) {
Log.d(
TAG,
"checkNotificationAccess: grant via Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"
)
}
}
// 构建加载失败错误页(转义系统错误描述,避免注入)
// Build the load-failure error page (escape the system error description)
private fun errorPage(desc: String): String {
val safe = TextUtils.htmlEncode(desc)
return """
<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body{font-family:sans-serif;display:flex;flex-direction:column;align-items:center;
justify-content:center;height:100vh;margin:0;background:transparent;color:#999}
h2{font-weight:400;color:#ccc}
p{font-size:14px;max-width:80vw;text-align:center;word-break:break-all}
</style></head><body><h2>加载失败</h2><p>$safe</p></body></html>
""".trimIndent()
}
// 进入沉浸式全屏:隐藏状态栏与导航栏,滑动可临时唤出
// Enter immersive fullscreen: hide status/navigation bars, swipe to reveal transiently
private fun enterImmersive() {
@@ -55,11 +558,19 @@ class MainActivity : Activity() {
}
}
// Back 键桌面态屏蔽(webAPP 打开时由 WebAppContainer 接管)
// Swallow Back key on desktop state (WebAppContainer takes over when a webAPP is open)
// 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() {
// 桌面态:吞掉,不响应
// Desktop state: consume the event, do not respond
val active = webAppContainer.tabs().firstOrNull { it.active }
if (active != null) {
if (!webAppHost.goBack()) {
bridge.closeWebApp(active.id)
}
Log.d(TAG, "onBackPressed: webAPP back/close, remaining tabs=${webAppContainer.tabs().size}")
return
}
Log.d(TAG, "onBackPressed: desktop state, swallowed")
}
}
@@ -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,78 @@
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) {
// 先解析、成功才缓存,避免畸形 body 被永久缓存
// Parse first, cache only on success (never cache malformed bodies)
val cards = parse(body)
if (cards != null) {
cache.save(KEY, body)
Log.d(TAG, "fetchCatalog: fetched ${body.length} bytes from network")
return cards
}
Log.w(TAG, "fetchCatalog: invalid catalog body, not cached")
return builtin()
}
val cached = cache.load(KEY)
if (cached != null) {
Log.d(TAG, "fetchCatalog: network failed, using cache")
return parse(cached) ?: builtin()
}
Log.d(TAG, "fetchCatalog: no network and no cache, return builtin time card")
return builtin()
}
// 解析目录 JSON{ "cards": [ {id,name,priority,entry} ] },字段缺失/非法时跳过该项;
// 畸形 JSON 或缺少 cards 字段返回 null(调用方回退内置 time 卡)
// Parse catalog JSON; skip malformed or missing-field entries. Return null on
// malformed JSON or a missing cards field (caller falls back to builtin time card)
private fun parse(body: String): List<Card>? {
val root = try {
gson.fromJson(body, Map::class.java)
} catch (e: Exception) {
Log.w(TAG, "parse: invalid catalog JSON: ${e.message}")
return null
}
val list = root["cards"] as? List<*> ?: return null
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,34 @@
package top.yeij.hearth.media
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
// 通知监听服务:作为「通知使用权」授权凭据(让 getActiveSessions 可用),
// 同时监听媒体通知的实时标题变化(如网易云歌词实时显示在通知标题)。
// Notification listener service: serves as the "notification access" credential
// (making getActiveSessions usable), and also watches media notification title
// changes (e.g. NetEase Cloud Music shows live lyrics in the notification title).
class HearthNotificationListenerService : NotificationListenerService() {
companion object {
private const val TAG = "HearthNotifListener"
// 媒体通知实时标题回调(packageName -> title),供 MediaSessionSource 订阅
// Media notification live-title callback (packageName -> title), subscribed by MediaSessionSource
@Volatile
var onMediaTitle: ((packageName: String, title: String) -> Unit)? = null
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
sbn ?: return
val n = sbn.notification ?: return
// 仅处理媒体通知(CATEGORY_TRANSPORT
// Only handle media notifications (CATEGORY_TRANSPORT)
if (n.category != Notification.CATEGORY_TRANSPORT) return
val title = n.extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: return
Log.d(TAG, "onNotificationPosted: ${sbn.packageName} title=$title")
onMediaTitle?.invoke(sbn.packageName, title)
}
}
@@ -0,0 +1,16 @@
package top.yeij.hearth.media
// 媒体会话快照:标题/艺术家/专辑 + 封面 + 播放状态/进度 + 来源包名
// Media session snapshot: title/artist/album + cover + playback state/progress + source package
data class MediaInfo(
val title: String,
val artist: String,
val album: String,
val cover: 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,265 @@
package top.yeij.hearth.media
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import java.io.ByteArrayOutputStream
// 监听系统活跃媒体会话(支持多个:音乐/听书/视频同时在线),
// 元数据/播放状态变化时实时回调(切歌、暂停/播放即时推送);
// 提供播放控制(上一首/暂停播放/下一首)、封面提取与跳转播放界面。
// Watch active media sessions (supports multiple: music/audiobook/video at once),
// push in real time on metadata/playback-state change (track switch, play/pause);
// provide transport controls (previous/play-pause/next), cover extraction and
// jumping to the playback UI.
class MediaSessionSource(context: Context) {
private val appContext = context.applicationContext
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)
// 通知实时标题缓存(packageName -> title),来自通知栏的实时歌词,
// 优先于 MediaSession 的 METADATA_KEY_TITLE(歌词只在通知里更新)
// Live notification title cache (packageName -> title) from the notification
// shade; it takes priority over MediaSession METADATA_KEY_TITLE because lyrics
// only update in the notification
private val notificationTitles = mutableMapOf<String, String>()
// 封面缓存(packageName -> base64):部分 App 切歌瞬间 ART 为 null,用缓存兜底,
// 避免媒体卡横划切换后封面消失
// Cover cache (packageName -> base64): some apps briefly return null ART on track
// switch; fall back to the cached cover so the card cover doesn't vanish on swipe
private val coverCache = mutableMapOf<String, String>()
init {
HearthNotificationListenerService.onMediaTitle = { pkg, title ->
notificationTitles[pkg] = title
pushCurrent()
}
}
private var callback: ((List<MediaInfo>) -> Unit)? = null
private var registered = false
private val controllers = mutableListOf<MediaController>()
// 轮询兜底:registerCallback 在部分场景不触发(如歌词实时更新时),
// 每 2 秒主动拉取一次进度/状态,保证进度条与信息持续更新
// Polling fallback: registerCallback doesn't always fire (e.g. live lyrics),
// so actively pull progress/state every 2s to keep the bar and info fresh
private val pollHandler = Handler(Looper.getMainLooper())
private val pollRunnable = object : Runnable {
override fun run() {
refresh()
pollHandler.postDelayed(this, 2000)
}
}
// 元数据/播放状态变化回调:切歌、暂停/播放时实时推送
// Metadata/playback-state change callback: push in real time on track switch or play/pause
private val mediaCallback = object : MediaController.Callback() {
override fun onMetadataChanged(metadata: MediaMetadata?) {
Log.d(TAG, "onMetadataChanged: ${metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)}")
pushCurrent()
}
override fun onPlaybackStateChanged(state: PlaybackState?) {
Log.d(TAG, "onPlaybackStateChanged: state=${state?.state}")
pushCurrent()
}
}
// 活跃会话列表变化时回调
// Fires when the active-session list changes
private val activeSessionsListener =
MediaSessionManager.OnActiveSessionsChangedListener { list ->
if (callback == null) return@OnActiveSessionsChangedListener
updateControllers(list?.toList() ?: emptyList())
pushCurrent()
}
// 注册监听:幂等——先移除旧监听再注册,避免重复 start 累积监听器。
// 注册后主动拉取一次当前会话:注册前已开始的播放不会触发变化回调。
// Register the listener: idempotent — remove the old one first to avoid accumulation.
// After registering, actively pull the current sessions once.
fun start(cb: (List<MediaInfo>) -> Unit) {
stop()
callback = cb
try {
msm.addOnActiveSessionsChangedListener(activeSessionsListener, listenerComponent)
registered = true
Log.d(TAG, "start: media session listener registered")
updateControllers(msm.getActiveSessions(listenerComponent))
pushCurrent()
// 启动轮询兜底
pollHandler.removeCallbacks(pollRunnable)
pollHandler.postDelayed(pollRunnable, 2000)
} catch (e: SecurityException) {
// 未授权「通知使用权」时系统抛 SecurityException:媒体监听降级为不可用,不崩溃。
// MediaSessionService throws SecurityException without notification access;
// degrade to media listening disabled instead of crashing.
Log.w(TAG, "start: no notification access, media listener disabled: ${e.message}")
registered = false
}
}
// 注销监听:释放回调引用,供 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")
}
unregisterCallbacks()
pollHandler.removeCallbacks(pollRunnable)
callback = null
}
// 主动拉取当前会话并回调(不重新注册监听),供 H5 页面就绪后刷新媒体卡
// Actively pull the current sessions and notify (without re-registering)
fun refresh() {
if (callback == null) return
try {
updateControllers(msm.getActiveSessions(listenerComponent))
pushCurrent()
} catch (e: SecurityException) {
Log.w(TAG, "refresh: no notification access: ${e.message}")
}
}
// 播放控制:作用于指定索引的 controller(对应 H5 当前滑到的会话)
// Transport controls: act on the controller at the given index (matching the
// session the H5 card is currently showing)
fun previous(index: Int) {
controllers.getOrNull(index)?.transportControls?.skipToPrevious()
Log.d(TAG, "previous: index=$index")
}
fun next(index: Int) {
controllers.getOrNull(index)?.transportControls?.skipToNext()
Log.d(TAG, "next: index=$index")
}
fun playPause(index: Int) {
val c = controllers.getOrNull(index) ?: return
val playing = c.playbackState?.state == PlaybackState.STATE_PLAYING
if (playing) c.transportControls.pause() else c.transportControls.play()
Log.d(TAG, "playPause: index=$index playing=$playing")
}
// 拖动进度条 seek 到指定位置(毫秒)
// Seek to the given position (ms) when dragging the progress bar
fun seekTo(index: Int, position: Long) {
controllers.getOrNull(index)?.transportControls?.seekTo(position)
Log.d(TAG, "seekTo: index=$index position=$position")
}
// 跳转到指定会话的播放界面:优先 sessionActivityPendingIntent),
// 失败则回退到启动该 App
// Jump to the session's playback UI: prefer sessionActivity (PendingIntent),
// fall back to launching the app
fun openMediaApp(index: Int) {
val c = controllers.getOrNull(index) ?: return
val sessionActivity = c.sessionActivity
if (sessionActivity != null) {
try {
sessionActivity.send()
Log.d(TAG, "openMediaApp: sessionActivity sent for ${c.packageName}")
return
} catch (e: Exception) {
Log.w(TAG, "openMediaApp: sessionActivity failed: ${e.message}")
}
}
val launch = appContext.packageManager.getLaunchIntentForPackage(c.packageName)
if (launch != null) {
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
appContext.startActivity(launch)
Log.d(TAG, "openMediaApp: launched ${c.packageName}")
}
}
// 更新 controller 列表:注销旧回调、注册新回调
// Update the controller list: unregister old callbacks, register new ones
private fun updateControllers(list: List<MediaController>) {
unregisterCallbacks()
controllers.clear()
controllers.addAll(list)
controllers.forEach { it.registerCallback(mediaCallback) }
}
private fun unregisterCallbacks() {
controllers.forEach {
try {
it.unregisterCallback(mediaCallback)
} catch (e: Exception) {
// ignore
}
}
}
// 推送当前全部会话的 MediaInfo 列表给 H5
// Push the current MediaInfo list for all sessions to H5
private fun pushCurrent() {
callback?.let { cb -> cb(controllers.map { toMediaInfo(it) }) }
}
// 从 controller 提取 metadata + playbackState + 封面构造 MediaInfo
// Build MediaInfo from the controller's metadata + playbackState + cover
private fun toMediaInfo(c: MediaController): MediaInfo {
val meta = c.metadata
val state = c.playbackState
// 标题优先用通知实时标题(歌词),fallback 到 MediaSession 元数据
// Title prefers the live notification title (lyrics), falling back to MediaSession metadata
val title = notificationTitles[c.packageName]
?: (meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "")
val duration = meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L
val position = state?.position ?: 0L
val playing = state?.state == PlaybackState.STATE_PLAYING
// 封面:优先当前 ART,为空时用缓存兜底(切歌瞬间 ART 可能为 null)
// Cover: prefer current ART, fall back to cache when empty (ART may be null
// briefly on track switch)
val cover = meta?.getBitmap(MediaMetadata.METADATA_KEY_ART)?.let { bitmapToBase64(it) }
?: coverCache[c.packageName]
?: ""
if (cover.isNotEmpty()) coverCache[c.packageName] = cover
Log.d(TAG, "media: ${c.packageName} duration=$duration position=$position playing=$playing cover=${cover.isNotEmpty()}")
return MediaInfo(
title,
meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "",
meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "",
cover,
playing,
position,
duration,
c.packageName,
)
}
// 封面缩放到 128pxJPEG 压缩转 base64data URI
// Scale the cover to 128px, JPEG-compress to base64 (data URI)
private fun bitmapToBase64(bmp: Bitmap): String {
val size = 128
val scaled = Bitmap.createScaledBitmap(bmp, size, size, true)
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.JPEG, 80, out)
scaled.recycle()
return "data:image/jpeg;base64," + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
}
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,75 @@
package top.yeij.hearth.webapp
// 单个 WebView 标签:id 唯一标识,active 表示是否为当前激活标签,name 为展示名
// Single WebView tab: id is the unique key, active marks the currently-focused tab,
// name is the display title (from the web app manifest)
data class Tab(val id: String, val url: String, val active: Boolean = false, val name: String = "")
// 多标签状态管理(纯 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
//
// 状态方法全部 @Synchronizedopen/close/switchTo 写操作在主线程,listTabs/tabs 读操作
// 在 JavaBridge 线程,跨线程读写同一 mutableListOf,需用同一把锁串行,避免
// ConcurrentModificationException(尤其 tabs() 的 toList() 迭代与写操作互斥)
// All state methods are @Synchronized: open/close/switchTo write on the main thread
// while listTabs/tabs read on the JavaBridge thread; reading and writing the same
// mutableListOf across threads must serialize on a single lock to avoid
// ConcurrentModificationException (especially toList() iteration vs writes)
class WebAppContainer {
private val tabs = mutableListOf<Tab>()
// 打开标签:已存在则激活它(不重复添加),否则新增并激活;name 记录展示名
// Open a tab: if it already exists just activate it, otherwise add and activate;
// name records the display title from the manifest
@Synchronized
fun open(id: String, url: String, name: 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, name = name))
}
// 关闭标签:若关闭后没有激活标签则激活第一个
// Close a tab: if none remain active, activate the first one
@Synchronized
fun close(id: String) {
tabs.removeAll { it.id == id }
if (tabs.isNotEmpty() && tabs.none { it.active }) {
tabs[0] = tabs[0].copy(active = true)
}
}
// 切换激活标签:id 不存在时返回 false 且不改动状态(保持原激活标签)
// Switch the active tab: return false and keep current state when id is unknown
@Synchronized
fun switchTo(id: String): Boolean {
if (tabs.none { it.id == id }) return false
setActive(id)
return true
}
// 返回标签列表副本(外部无法直接改动内部状态)
// Return a defensive copy of the tab list
@Synchronized
fun tabs(): List<Tab> = tabs.toList()
// 返回当前激活标签 id,无标签或未激活返回 null
// Return the active tab id, or null when empty/none active
@Synchronized
fun activeTabId(): String? = tabs.firstOrNull { it.active }?.id
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,146 @@
package top.yeij.hearth.webapp
import android.util.Base64
import android.util.Log
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,
val offlineVersion: String? = null,
val ua: String? = null,
val scale: Int? = 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 var serverUrl: String,
) {
private val gson = Gson()
// 更新服务器根地址(设置页修改服务器地址后调用)
// Update the server root URL (called after the settings page changes it)
fun setServerUrl(url: String) {
serverUrl = url
Log.d(TAG, "setServerUrl: $url")
}
fun fetchManifest(): List<WebApp> {
// 服务器地址是根目录,清单固定拉 <根>/manifest.json
// The server URL is the root; the manifest is always <root>/manifest.json
val manifestUrl = serverUrl.trimEnd('/') + "/manifest.json"
val body = http.get(manifestUrl)
Log.d(TAG, "fetchManifest: url=$manifestUrl bodyNull=${body == null}")
if (body != null) {
// 先解析、成功才缓存,避免畸形 body 或空结果被永久缓存
// Parse first, cache only on success (never cache malformed/empty bodies)
val apps = parse(body)
if (apps != null && apps.isNotEmpty()) {
cache.save(KEY, body)
return apps
}
Log.w(TAG, "fetchManifest: invalid/empty body, not cached")
return builtinWebApps()
}
val cached = cache.load(KEY)
if (cached != null) {
val apps = parse(cached)
if (apps != null && apps.isNotEmpty()) return apps
}
// 无远程清单:返回内置示例 webapp,保证列表不为空、可演示
// No remote manifest: return the builtin demo webapp so the list is non-empty
return builtinWebApps()
}
// 内置示例 webapp(打包在 assets 内,无需服务器)
// Builtin demo webapp (bundled in assets, no server required)
private fun builtinWebApps(): List<WebApp> {
val svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>" +
"<rect width='24' height='24' rx='6' fill='#ff6900'/>" +
"<text x='12' y='17' font-size='13' text-anchor='middle' fill='white'>H</text></svg>"
val icon = "data:image/svg+xml;base64," +
Base64.encodeToString(svg.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
return listOf(
WebApp(
id = "demo",
name = "示例应用",
icon = icon,
url = "file:///android_asset/webapps/demo/index.html",
)
)
}
// 解析清单 JSON;畸形 JSON 或缺少 apps 字段返回 null(调用方决定回退)
// Parse the manifest JSON; return null on malformed JSON / missing apps field
private fun parse(body: String): List<WebApp>? {
val root = try {
gson.fromJson(body, Map::class.java)
} catch (e: Exception) {
Log.w(TAG, "parse: invalid manifest JSON: ${e.message}")
return null
}
val apps = root["apps"] as? List<*> ?: return null
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/version 字段,否则 null(纯在线)
// offline is an object -> take its package/version fields, otherwise null (online-only)
val offlineMap = map["offline"] as? Map<*, *>
val offline = offlineMap?.get("package") as? String
val offlineVersion = offlineMap?.get("version") as? String
// ua 可选:desktop/pc 用 PC UA,其他视为自定义 UA 字符串,不指定默认平板 UA
// ua optional: desktop/pc uses a desktop UA, others are custom UA strings,
// absent means the default tablet UA
val ua = map["ua"] as? String
// scale 可选:初始缩放百分比(1-100),用于 PC 版页面缩放适配横屏
// scale optional: initial zoom percentage (1-100), for scaling desktop pages
val scale = (map["scale"] as? Number)?.toInt()
WebApp(
id, name,
resolvePath(icon),
resolvePath(url),
offline?.let { resolvePath(it) },
offlineVersion,
ua,
scale,
)
}
}
companion object {
private const val TAG = "HearthWebApp"
private const val KEY = "webapp-manifest"
}
// 相对路径拼服务器根;绝对 URL / data URI / file URI 直接返回
// Resolve a relative path against the server root; return absolute URL / data
// URI / file URI as-is
private fun resolvePath(path: String): String {
if (path.startsWith("http://") || path.startsWith("https://") ||
path.startsWith("data:") || path.startsWith("file:")
) {
return path
}
return serverUrl.trimEnd('/') + "/" + path.trimStart('/')
}
}
@@ -0,0 +1,160 @@
package top.yeij.hearth.webapp
import android.content.Context
import android.net.Uri
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
import okhttp3.OkHttpClient
import okhttp3.Request
// 离线包存储:下载/解压/手动导入 webAPP 离线包到本地目录,加载时本地优先。
// 本地已解压的离线包不依赖服务端(服务端删除后仍可加载运行)。
// Offline-package storage: download/extract/manually-import a webapp's offline package
// into the local directory; loading prefers local. A locally-extracted package does
// not depend on the server (still loads after the server removes it).
class WebAppStorage(private val context: Context) {
private val client = OkHttpClient()
private val rootDir: File by lazy { File(context.filesDir, "webapps") }
private fun appDir(id: String): File = File(rootDir, id)
// 本地是否已有离线包(解压目录含 index.html
// Whether a local offline package exists (extracted dir has index.html)
fun hasLocalPackage(id: String): Boolean =
File(appDir(id), "index.html").exists()
// 本地离线包入口 URL(file:// 绝对路径),无则 null
// canonicalPath 解析 /data/user/0 -> /data/data 符号链接,避免 WebView 的
// ERR_ACCESS_DENIED
// Local offline-package entry URL (file:// absolute path), null when absent.
// canonicalPath resolves the /data/user/0 -> /data/data symlink, avoiding the
// WebView ERR_ACCESS_DENIED
fun localIndexUrl(id: String): String? {
if (!hasLocalPackage(id)) return null
val f = File(appDir(id), "index.html")
val path = try {
f.canonicalPath
} catch (e: Exception) {
f.absolutePath
}
return "file://" + path
}
// 本地离线包版本号(解压目录里的 .version 文件),无则 null
// Local offline-package version (from the .version file), null when absent
fun getLocalVersion(id: String): String? {
val f = File(appDir(id), ".version")
return if (f.exists()) f.readText().trim().takeIf { it.isNotEmpty() } else null
}
// 删除本地离线包
// Delete the local offline package
fun deleteLocalPackage(id: String) {
val deleted = appDir(id).deleteRecursively()
Log.d(TAG, "deleteLocalPackage: id=$id deleted=$deleted")
}
// 下载并解压离线包(记录版本号),onProgress 回调 0-100
// Download and extract the offline package (record its version), onProgress 0-100
fun downloadAndExtract(id: String, packageUrl: String, version: String, onProgress: (Int) -> Unit): Boolean {
val tmp = File(context.cacheDir, "$id-offline.zip")
return try {
val request = Request.Builder().url(packageUrl).build()
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
Log.w(TAG, "downloadAndExtract: http ${resp.code} for $packageUrl")
return false
}
val body = resp.body ?: return false
val total = body.contentLength()
val input = body.byteStream()
FileOutputStream(tmp).use { output ->
val buf = ByteArray(8192)
var read = 0L
var n = input.read(buf)
while (n >= 0) {
output.write(buf, 0, n)
read += n
if (total > 0) onProgress(((read * 100) / total).toInt())
n = input.read(buf)
}
}
}
val ok = extractZip(tmp, appDir(id))
if (ok) writeVersion(id, version)
tmp.delete()
ok
} catch (e: Exception) {
Log.e(TAG, "downloadAndExtract failed: ${e.message}")
tmp.delete()
false
}
}
// 手动导入离线包:从 uri 复制 zip 并解压(版本记为 manual
// Manually import an offline package: copy the zip from uri and extract (version "manual")
fun importPackage(id: String, uri: Uri): Boolean {
val tmp = File(context.cacheDir, "$id-import.zip")
return try {
val input = context.contentResolver.openInputStream(uri) ?: return false
FileOutputStream(tmp).use { output -> input.use { it.copyTo(output) } }
val ok = extractZip(tmp, appDir(id))
if (ok) writeVersion(id, "manual")
tmp.delete()
ok
} catch (e: Exception) {
Log.e(TAG, "importPackage failed: ${e.message}")
tmp.delete()
false
}
}
// 记录本地离线包版本号(.version 文件)
// Record the local offline-package version (a .version file)
private fun writeVersion(id: String, version: String) {
try {
File(appDir(id), ".version").writeText(version)
} catch (e: Exception) {
Log.w(TAG, "writeVersion failed: ${e.message}")
}
}
// 解压 zip 到目标目录(先清空旧目录),校验 index.html 存在
// Extract the zip into the destination (clear it first), verify index.html exists
private fun extractZip(zip: File, dest: File): Boolean {
return try {
dest.deleteRecursively()
dest.mkdirs()
ZipInputStream(zip.inputStream()).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
// 防 zip 路径穿越:规范化名称,丢弃含 ../ 的条目
// Zip-slip guard: normalize the name, drop entries with ../
val name = entry.name.replace('\\', '/')
if (name.contains("..")) {
entry = zis.nextEntry
continue
}
val outFile = File(dest, name)
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { zis.copyTo(it) }
}
entry = zis.nextEntry
}
}
File(dest, "index.html").exists()
} catch (e: Exception) {
Log.e(TAG, "extractZip failed: ${e.message}")
false
}
}
companion object {
private const val TAG = "HearthWebAppStorage"
}
}
@@ -1,15 +1,101 @@
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.MediaInfo
import top.yeij.hearth.media.MediaSessionSource
import top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebApp
import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webapp.WebAppStorage
// 通知使用权访问接口:由 MainActivity 实现,JsBridge 通过它检查/申请授权,
// 保持 JsBridge 可 JVM 单测(不直接依赖 Context/NotificationManagerCompat
// Notification-access provider implemented by MainActivity; JsBridge uses it to
// check/request access, keeping JsBridge JVM-testable (no direct Context dependency)
interface NotificationAccessProvider {
fun isGranted(): Boolean
fun requestAccess()
}
// 系统亮度访问接口:由 MainActivity 实现(Settings.System + WRITE_SETTINGS 授权)
// System brightness provider implemented by MainActivity (Settings.System + WRITE_SETTINGS)
interface BrightnessProvider {
fun getSystemBrightness(): Int
fun setSystemBrightness(value: Int)
fun canWriteSettings(): Boolean
fun requestWriteSettings()
}
// 服务器地址存储接口:由 MainActivity 实现(SharedPreferences 持久化)
// Server URL storage provider implemented by MainActivity (SharedPreferences)
interface ServerUrlProvider {
fun getServerUrl(): String
fun setServerUrl(url: String)
}
// 壁纸提供接口:由 MainActivity 实现(WallpaperManager 转 base64),
// 让壁纸进入 WebView 内部,backdrop-filter 玻璃才能模糊到壁纸
// Wallpaper provider implemented by MainActivity (WallpaperManager -> base64), so the
// wallpaper lives inside the WebView and the glass backdrop-filter can blur it
interface WallpaperProvider {
fun getWallpaperBase64(): String
}
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 webAppContainer: WebAppContainer? = null,
private val webAppHost: WebAppHost? = null,
// 主线程执行器:View 操作必须切到主线程;null(单测)时同步执行
// Main-thread executor: view ops must run on main; null (unit tests) runs inline
private val postToMainThread: ((() -> Unit) -> Unit)? = null,
// 通知使用权访问接口(检查/申请授权),供设置页授权项使用
// Notification-access provider (check/request) for the settings permission item
private val notificationAccess: NotificationAccessProvider? = null,
// 系统亮度接口(读取/设置 + WRITE_SETTINGS 授权)
// System brightness provider (get/set + WRITE_SETTINGS grant)
private val brightness: BrightnessProvider? = null,
// 服务器地址存储接口
// Server URL storage provider
private val serverUrl: ServerUrlProvider? = null,
// 壁纸提供接口(base64
// Wallpaper provider (base64)
private val wallpaper: WallpaperProvider? = null,
// 标签面板回调:由 MainActivity 实现,弹出原生标签面板(PopupWindow,在内容
// WebView 之上,不占用预留高度)
// Tab panel callback implemented by MainActivity: shows a native tab panel
// (PopupWindow above the content WebView, no reserved height)
private val onShowTabPanel: (() -> Unit)? = null,
// 离线包存储(下载/解压/导入/本地检查)
// Offline-package storage (download/extract/import/local check)
private val storage: WebAppStorage? = null,
// 手动导入离线包回调:由 MainActivity 实现(SAF 选择 zip 后导入)
// Manual offline-package import callback implemented by MainActivity (SAF pick + import)
private val onImportOfflinePackage: ((String) -> Unit)? = null,
// 导出日志回调:由 MainActivity 实现,返回导出结果描述(文件名/错误)
// Export-log callback implemented by MainActivity, returns a result description
private val onExportLog: (() -> String)? = null,
) {
private val gson = com.google.gson.Gson()
// 桌面 WebView 引用(媒体/进度推送用),setMediaListener 时保存
// Desktop WebView reference (for media/progress push), saved in setMediaListener
private var desktopWebViewRef: android.webkit.WebView? = null
// 清单内存缓存:openWebApp 首次拉取后缓存,后续复用避免重复 I/O
// In-memory manifest cache: cached after first fetch to avoid repeated I/O
@Volatile
private var manifestCache: List<WebApp>? = null
@android.webkit.JavascriptInterface
fun getDeviceInfo(): String {
Log.d("HearthBridge", "getDeviceInfo called")
@@ -22,4 +108,407 @@ class JsBridge(
)
)
}
// 返回已安装应用列表 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(无仓库时返回空数组),并写入内存缓存供 openWebApp 复用
// Return the H5 web app manifest JSON (empty array when no repository wired) and
// seed the in-memory cache for openWebApp reuse
@android.webkit.JavascriptInterface
fun fetchWebApps(): String {
val apps = manifest()
return gson.toJson(apps)
}
// 打开指定 id 的 webAPP:本地离线包优先 → 有离线包则下载解压(带进度)→ 否则远程加载
// Open a web app by id: prefer the local offline package -> download+extract (with
// progress) if it has an offline package -> otherwise load the remote URL
@android.webkit.JavascriptInterface
fun openWebApp(id: String) {
val container = webAppContainer ?: return
val host = webAppHost ?: return
val app = manifest().firstOrNull { it.id == id }
if (app == null) {
Log.d("HearthBridge", "openWebApp: unknown id=$id")
return
}
val st = storage
// 1. 本地离线包优先(服务端删除后仍可用)
val localUrl = st?.localIndexUrl(id)
if (localUrl != null) {
openWebAppAt(container, host, id, localUrl, app.name, app.ua, app.scale)
return
}
// 2. 有离线包 → 后台下载解压(推送进度),完成后加载本地;失败回退远程
val pkg = app.offline
if (pkg != null && st != null) {
pushWebappProgress(id, 0)
Thread {
val fullUrl = resolveUrl(pkg)
st.downloadAndExtract(id, fullUrl, app.offlineVersion ?: "") { p -> pushWebappProgress(id, p) }
val url = st.localIndexUrl(id) ?: app.url
openWebAppAt(container, host, id, url, app.name, app.ua, app.scale)
pushWebappProgress(id, 100)
}.start()
return
}
// 3. 无离线包 → 远程加载(WebView 自带加载进度)
openWebAppAt(container, host, id, app.url, app.name, app.ua, app.scale)
}
// 在主线程打开指定 url 的 webAPP 标签
// Open a webapp tab at the given url on the main thread
private fun openWebAppAt(container: WebAppContainer, host: WebAppHost, id: String, url: String, name: String, ua: String?, scale: Int?) {
onMain {
container.open(id, url, name)
host.openWebView(id, url, ua, scale)
host.syncTabs(container.tabs())
}
Log.d("HearthBridge", "openWebApp: id=$id url=$url ua=$ua scale=$scale")
}
// 手动导入离线包(由 MainActivity 触发 SAF 选择 zip
// Manually import an offline package (MainActivity triggers SAF to pick a zip)
@android.webkit.JavascriptInterface
fun importOfflinePackage(id: String) {
Log.d("HearthBridge", "importOfflinePackage: id=$id")
onImportOfflinePackage?.invoke(id)
}
// 本地是否已有离线包(用于列表显示「离线」标签)
// Whether a local offline package exists (for the list's "offline" badge)
@android.webkit.JavascriptInterface
fun hasLocalPackage(id: String): Boolean = storage?.hasLocalPackage(id) ?: false
// 导出日志到 Download 目录,返回结果描述(文件名或错误)
// Export the log to the Download directory, return a result description
@android.webkit.JavascriptInterface
fun exportLog(): String {
Log.d("HearthBridge", "exportLog called")
return onExportLog?.invoke() ?: "日志导出未实现"
}
// 本地离线包版本号(无则空字符串)
// Local offline-package version (empty string when absent)
@android.webkit.JavascriptInterface
fun getLocalVersion(id: String): String = storage?.getLocalVersion(id) ?: ""
// 删除本地离线包
// Delete the local offline package
@android.webkit.JavascriptInterface
fun deleteLocalPackage(id: String) {
Log.d("HearthBridge", "deleteLocalPackage: id=$id")
storage?.deleteLocalPackage(id)
}
// 强制更新离线包:删除本地后重新下载
// Force-update the offline package: delete local then re-download
@android.webkit.JavascriptInterface
fun updateOfflinePackage(id: String) {
Log.d("HearthBridge", "updateOfflinePackage: id=$id")
val st = storage ?: return
val app = manifest().firstOrNull { it.id == id } ?: return
val pkg = app.offline ?: return
st.deleteLocalPackage(id)
pushWebappProgress(id, 0)
Thread {
val fullUrl = resolveUrl(pkg)
st.downloadAndExtract(id, fullUrl, app.offlineVersion ?: "") { p -> pushWebappProgress(id, p) }
pushWebappProgress(id, 100)
}.start()
}
// 推送 webapp 加载/下载进度给 H50-100
// Push webapp load/download progress to H5 (0-100)
private fun pushWebappProgress(id: String, progress: Int) {
val wv = desktopWebViewRef ?: return
wv.post {
wv.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappProgress('$id', $progress);",
null
)
}
}
// 离线包路径解析:绝对 URL 直接用,相对路径拼服务器根
// Resolve the offline-package path: absolute URL as-is, relative joined to the server root
private fun resolveUrl(relative: String): String {
if (relative.startsWith("http://") || relative.startsWith("https://")) return relative
val base = serverUrl?.getServerUrl()?.substringBeforeLast('/') ?: return relative
return "$base/$relative"
}
// 关闭指定 id 的标签:移除标签 → 主线程销毁 WebView → 若剩标签激活第一个 → 同步顶栏
// Close a tab by id: remove tab -> destroy WebView on main thread -> activate the
// first remaining tab -> sync the topbar
@android.webkit.JavascriptInterface
fun closeWebApp(id: String) {
val container = webAppContainer ?: return
val host = webAppHost ?: return
onMain {
container.close(id)
val nextActiveId = container.activeTabId()
host.closeWebView(id)
nextActiveId?.let { host.switchWebView(it) }
host.syncTabs(container.tabs())
Log.d("HearthBridge", "closeWebApp: id=$id remaining=${container.tabs().size}")
}
}
// 切换激活标签:校验 id 存在后更新状态 → 主线程切换 WebView 可见性 → 同步顶栏
// Switch the active tab: validate id exists, update state -> switch WebView
// visibility on main thread -> sync the topbar
@android.webkit.JavascriptInterface
fun switchTab(id: String) {
val container = webAppContainer ?: return
val host = webAppHost ?: return
onMain {
if (container.switchTo(id)) {
host.switchWebView(id)
host.syncTabs(container.tabs())
}
}
Log.d("HearthBridge", "switchTab: id=$id")
}
// 返回已打开标签列表 JSONid/name/active
// Return the open tab list JSON (id/name/active)
@android.webkit.JavascriptInterface
fun listTabs(): String {
val container = webAppContainer ?: return "[]"
val tabs = container.tabs().map { tab ->
mapOf("id" to tab.id, "name" to tab.name, "active" to tab.active)
}
return gson.toJson(tabs)
}
// 当前激活标签回退(主线程执行 WebView 导航)
// Go back on the active tab (WebView navigation runs on main thread)
@android.webkit.JavascriptInterface
fun webGoBack() {
val host = webAppHost ?: return
onMain { host.goBack() }
Log.d("HearthBridge", "webGoBack called")
}
// 当前激活标签前进(主线程执行 WebView 导航)
// Go forward on the active tab (WebView navigation runs on main thread)
@android.webkit.JavascriptInterface
fun webGoForward() {
val host = webAppHost ?: return
onMain { host.goForward() }
Log.d("HearthBridge", "webGoForward called")
}
// 当前激活标签重载(主线程执行 WebView 导航)
// Reload the active tab (WebView navigation runs on main thread)
@android.webkit.JavascriptInterface
fun webReload() {
val host = webAppHost ?: return
onMain { host.reload() }
Log.d("HearthBridge", "webReload called")
}
// 隐藏所有内容 WebView(把前台 webapp 丢到后台,标签状态保留)
// Hide all content WebViews (send the foreground webapp to background,
// keeping the tab state) — called when the user switches pages via the sidebar
@android.webkit.JavascriptInterface
fun hideWebApps() {
val host = webAppHost ?: return
onMain { host.hideAll() }
Log.d("HearthBridge", "hideWebApps called")
}
// 弹出原生标签面板(H5 顶栏标签按钮点击时调用)
// Show the native tab panel (called when the H5 topbar tab button is tapped)
@android.webkit.JavascriptInterface
fun showTabPanel() {
Log.d("HearthBridge", "showTabPanel called")
onShowTabPanel?.invoke()
}
// 清单内存缓存:首次拉取成功后缓存,后续复用(避免 openWebApp 重复 I/O
// 空结果(离线失败)不缓存,下次调用重试拉取,避免空清单被永久缓存
// In-memory manifest cache: cache only on a successful non-empty fetch (avoid
// repeated I/O); empty results (offline failure) are never cached so the next
// call retries instead of being permanently stuck with an empty manifest
private fun manifest(): List<WebApp> {
manifestCache?.let { return it }
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
if (apps.isNotEmpty()) manifestCache = apps
return apps
}
// 将操作派发到主线程:未注入执行器(单测)时同步执行
// Dispatch to the main thread; run inline when no executor is injected (unit tests)
private fun onMain(block: () -> Unit) {
val post = postToMainThread
if (post == null) block() else post(block)
}
// 返回首页卡片目录 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)
}
// 返回通知使用权是否已授权(未接入返回 false)
// Return whether notification access is granted (false when not wired)
@android.webkit.JavascriptInterface
fun getNotificationAccess(): Boolean = notificationAccess?.isGranted() ?: false
// 跳转系统「通知使用权」设置页,让用户为媒体卡授权
// Jump to the system notification-access settings page for media-card grant
@android.webkit.JavascriptInterface
fun requestNotificationAccess() {
Log.d("HearthBridge", "requestNotificationAccess called")
notificationAccess?.requestAccess()
}
// 返回系统亮度(0-255,未接入返回 -1)
// Return system brightness (0-255, -1 when not wired)
@android.webkit.JavascriptInterface
fun getSystemBrightness(): Int = brightness?.getSystemBrightness() ?: -1
// 设置系统亮度(0-255
// Set system brightness (0-255)
@android.webkit.JavascriptInterface
fun setSystemBrightness(value: Int) {
brightness?.setSystemBrightness(value)
}
// 是否已授予「修改系统设置」权限(WRITE_SETTINGS
// Whether the WRITE_SETTINGS permission is granted
@android.webkit.JavascriptInterface
fun canWriteSettings(): Boolean = brightness?.canWriteSettings() ?: false
// 跳转系统「修改系统设置」授权页
// Jump to the system WRITE_SETTINGS grant page
@android.webkit.JavascriptInterface
fun requestWriteSettings() {
brightness?.requestWriteSettings()
}
// 返回 webAPP 服务器地址(未接入返回空字符串)
// Return the webAPP server URL (empty string when not wired)
@android.webkit.JavascriptInterface
fun getServerUrl(): String = serverUrl?.getServerUrl() ?: ""
// 设置 webAPP 服务器地址(持久化 + 后续拉取使用)
// Set the webAPP server URL (persisted and used by subsequent fetches)
@android.webkit.JavascriptInterface
fun setServerUrl(url: String) {
Log.d("HearthBridge", "setServerUrl: $url")
serverUrl?.setServerUrl(url)
}
// 返回壁纸 base64(data URI,未接入返回空字符串)
// Return the wallpaper base64 (data URI; empty string when not wired)
@android.webkit.JavascriptInterface
fun getWallpaper(): String = wallpaper?.getWallpaperBase64() ?: ""
// 重新拉取 webAPP 清单 + 卡片目录,返回 { added, removed, changed }
// Re-fetch the webAPP manifest + card catalog, return { added, removed, changed }
@android.webkit.JavascriptInterface
fun refreshManifest(): String {
val oldApps = manifestCache ?: emptyList<WebApp>()
manifestCache = null
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
if (apps.isNotEmpty()) manifestCache = apps
cardRepository?.fetchCatalog()
// 增量计算:新增 / 移除 / 更新(离线包版本或 URL/图标变化)
// Diff: added / removed / changed (offline version or URL/icon changed)
val oldIds = oldApps.map { it.id }.toSet()
val newIds = apps.map { it.id }.toSet()
val added = (newIds - oldIds).size
val removed = (oldIds - newIds).size
val changed = apps.count { new ->
val old = oldApps.find { it.id == new.id }
old != null && (old.offlineVersion != new.offlineVersion ||
old.url != new.url || old.icon != new.icon)
}
Log.d("HearthBridge", "refreshManifest: added=$added removed=$removed changed=$changed (${apps.size} apps)")
// 推送 H5 刷新列表(清 preload 缓存并重渲染)
// Push H5 to refresh the list (clear preload cache and re-render)
desktopWebViewRef?.post {
desktopWebViewRef?.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.webappUpdated();",
null
)
}
return gson.toJson(mapOf("added" to added, "removed" to removed, "changed" to changed))
}
// 注册媒体会话监听,回调推送给 H5(info 为 null 时推 "null"
// Register media session listener; push callbacks to H5 (push "null" when info is null)
private var mediaSourceRef: MediaSessionSource? = null
fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
mediaSourceRef = source
desktopWebViewRef = webView
source.start { infos -> pushMedia(infos, webView) }
}
// H5 页面就绪后重新拉取一次媒体:修复启动时推送早于页面渲染的时序问题
// Re-pull media once the H5 page is ready: fixes the startup timing where the
// push happens before the page has rendered
fun refreshMedia() {
mediaSourceRef?.refresh()
}
// 媒体播放控制(作用于指定索引的会话,对应 H5 当前滑到的卡片)
// Media transport controls (act on the session at the given index, matching
// the card the H5 is currently showing)
@android.webkit.JavascriptInterface
fun mediaPrevious(index: Int) {
mediaSourceRef?.previous(index)
}
@android.webkit.JavascriptInterface
fun mediaPlayPause(index: Int) {
mediaSourceRef?.playPause(index)
}
@android.webkit.JavascriptInterface
fun mediaNext(index: Int) {
mediaSourceRef?.next(index)
}
// 拖动进度条 seek 到指定位置(毫秒)
// Seek to the given position (ms) when dragging the progress bar
@android.webkit.JavascriptInterface
fun mediaSeekTo(index: Int, position: Long) {
mediaSourceRef?.seekTo(index, position)
}
// 跳转到指定会话的播放界面(封面点击)
// Jump to the session's playback UI (cover tap)
@android.webkit.JavascriptInterface
fun openMediaApp(index: Int) {
mediaSourceRef?.openMediaApp(index)
}
private fun pushMedia(infos: List<MediaInfo>, webView: android.webkit.WebView) {
webView.post {
val json = gson.toJson(infos)
webView.evaluateJavascript(
"window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);",
null
)
}
}
}
@@ -0,0 +1,45 @@
package top.yeij.hearth.webview
import top.yeij.hearth.webapp.Tab
// webAPP 内容 WebView 宿主接口:由 MainActivity 实现,管理多 WebView 的生命周期、
// 可见性、导航,并把标签状态同步回桌面 H5 顶栏
// Web app content WebView host interface: implemented by MainActivity, managing
// multi-WebView lifecycle, visibility, and navigation, and syncing tab state
// back to the desktop H5 topbar
interface WebAppHost {
// 创建内容 WebView 加载 url 并显示(已存在则切换到该标签);ua 为清单声明的 UA,
// scale 为清单声明的初始缩放百分比(可为 null)
// Create a content WebView loading url and show it (switch if already open);
// ua is the manifest-declared UA, scale is the initial zoom percentage (nullable)
fun openWebView(id: String, url: String, ua: String?, scale: Int?)
// 销毁对应内容 WebView
// Destroy the corresponding content WebView
fun closeWebView(id: String)
// 切换可见内容 WebView(其它内容 WebView GONE
// Switch the visible content WebView (hide the others with GONE)
fun switchWebView(id: String)
// 当前可见 WebView 回退,无历史返回 false
// Go back on the visible WebView, false when no history
fun goBack(): Boolean
// 当前可见 WebView 前进,无历史返回 false
// Go forward on the visible WebView, false when no history
fun goForward(): Boolean
// 当前可见 WebView 重载
// Reload the visible WebView
fun reload()
// 将标签状态推送到桌面 H5 顶栏
// Push the tab state to the desktop H5 topbar
fun syncTabs(tabs: List<Tab>)
// 隐藏所有内容 WebView(把前台 webapp 丢到后台,标签状态保留)
// Hide all content WebViews (send the foreground webapp to background,
// keeping the tab state)
fun hideAll()
}
@@ -2,25 +2,80 @@ package top.yeij.hearth.webview
import android.annotation.SuppressLint
import android.app.Activity
import android.graphics.Color
import android.view.ViewGroup
import android.webkit.WebView
import android.webkit.WebSettings
import android.webkit.WebViewClient
// WebView 工厂:统一内核配置,桌面层挂到指定父容器,内容层按需创建
// WebView factory: unified kernel config; the desktop layer attaches to a given
// parent container, content layers are created on demand
class WebViewManager(private val activity: Activity) {
// 创建桌面 WebView(底层 H5)挂到 parent,加载内置 index.html
// Create the desktop WebView (bottom H5 layer) under parent, load bundled index.html
@SuppressLint("SetJavaScriptEnabled")
fun attach(bridge: JsBridge): WebView {
fun attach(bridge: JsBridge, parent: ViewGroup): WebView {
val webView = WebView(activity)
webView.setBackgroundColor(Color.TRANSPARENT)
configure(webView)
webView.addJavascriptInterface(bridge, "HearthBridge")
webView.webViewClient = WebViewClient()
webView.loadUrl("file:///android_asset/h5/index.html")
parent.addView(webView, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
return webView
}
// 创建内容 WebView(webAPP 标签页),仅配置不加载、不挂载(由宿主管理)
// 透明背景:webAPP 未声明背景色时透出桌面壁纸,而非默认白底
// Create a content WebView (webapp tab), configured but not loaded/attached
// (lifecycle is managed by the host); transparent background so a webapp without
// a declared background shows the wallpaper instead of a default white
@SuppressLint("SetJavaScriptEnabled")
fun createContentWebView(ua: String?, scale: Int?): WebView {
val webView = WebView(activity)
webView.setBackgroundColor(Color.TRANSPARENT)
configure(webView)
// UA 解析:desktop/pc → PC UA;自定义字符串 → 直接用;null → 默认平板 UA
// UA resolution: desktop/pc -> desktop UA; custom string -> used as-is;
// null -> default tablet UA
webView.settings.userAgentString = when {
ua == null -> UA_TABLET
ua == "desktop" || ua == "pc" -> UA_DESKTOP
else -> ua
}
// scale 由 WebAppHost 在 onPageFinished 里通过 CSS zoom 实现(reflow 缩放),
// 此处不做缩放
// scale is applied by WebAppHost via CSS zoom in onPageFinished (reflow
// scaling); no scaling here
return webView
}
// 统一内核配置:JS、宽视口、DOM 存储,禁用缩放
// Common kernel config: JS, wide viewport, DOM storage, zoom disabled
@SuppressLint("SetJavaScriptEnabled")
private fun configure(webView: WebView) {
webView.settings.apply {
javaScriptEnabled = true
useWideViewPort = true
loadWithOverviewMode = true
setSupportZoom(false)
domStorageEnabled = true
// Android 11 + targetSdk 30 默认禁用 file:// 访问,本地离线包需要显式开启
// Android 11 + targetSdk 30 disables file:// by default; local offline
// packages need it explicitly enabled
allowFileAccess = true
allowContentAccess = true
}
webView.addJavascriptInterface(bridge, "HearthBridge")
webView.webViewClient = WebViewClient()
webView.loadUrl("file:///android_asset/h5/index.html")
activity.setContentView(webView)
return webView
}
companion object {
// PC 版 UAdesktop/pc
private const val UA_DESKTOP =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
// 安卓平板 UA(默认)
private const val UA_TABLET =
"Mozilla/5.0 (Linux; Android 13; SM-X700 Build/TP1A.220624.014) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
}
+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,68 @@
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()
}
@Test
fun fetchCatalog_malformedBody_returnsBuiltin_andDoesNotCache() {
val dir = File(System.getProperty("java.io.tmpdir"), "c4")
val cm = CacheManager(dir)
val http = object : HttpClient { override fun get(url: String) = "{broken json" }
val repo = CardRepository(http, cm, "http://fake")
// 畸形 body 不崩溃、回退内置 time 卡,且不得写入缓存
// Malformed body: no crash, fall back to builtin time card, and must not be cached
val cards = repo.fetchCatalog()
assertEquals(1, cards.size)
assertEquals("time", cards[0].id)
assertEquals(null, cm.load("card-catalog"))
dir.deleteRecursively()
}
}
@@ -0,0 +1,86 @@
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)
}
@Test
fun open_withName_recordsDisplayName() {
val c = WebAppContainer()
c.open("a", "http://x/a", "云音乐")
assertEquals("云音乐", c.tabs()[0].name)
}
@Test
fun activeTabId_returnsActiveId_orNullWhenEmpty() {
val c = WebAppContainer()
assertEquals(null, c.activeTabId())
c.open("a", "http://x/a")
c.open("b", "http://x/b")
assertEquals("b", c.activeTabId())
}
@Test
fun switchTo_unknownId_keepsCurrentActiveAndReturnsFalse() {
val c = WebAppContainer()
c.open("a", "http://x/a")
c.open("b", "http://x/b")
assertTrue(!c.switchTo("unknown"))
assertEquals("b", c.tabs().first { it.active }.id)
}
}
@@ -0,0 +1,59 @@
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()
}
@Test
fun fetchManifest_malformedBody_returnsEmpty_andDoesNotCache() {
val dir = File(System.getProperty("java.io.tmpdir"), "w4")
val cm = CacheManager(dir)
val http = object : HttpClient {
override fun get(url: String) = "{not valid json"
}
val repo = WebAppRepository(http, cm, "http://fake")
// 畸形 body 不崩溃、返回空,且不得写入缓存
// Malformed body: no crash, empty result, and must not be cached
assertTrue(repo.fetchManifest().isEmpty())
assertEquals(null, cm.load("webapp-manifest"))
dir.deleteRecursively()
}
}
@@ -1,7 +1,34 @@
package top.yeij.hearth.webview
import org.junit.Assert.assertEquals
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 top.yeij.hearth.webapp.Tab
import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository
import java.io.File
// 记录宿主方法调用,用于验证 JsBridge → WebAppHost 的接线
// Record host method calls to verify JsBridge -> WebAppHost wiring
private class FakeHost : WebAppHost {
var openedId: String? = null
var openedUrl: String? = null
val closedIds = mutableListOf<String>()
var switchedId: String? = null
var syncedTabs: List<Tab>? = null
var backCalls = 0
override fun openWebView(id: String, url: String) { openedId = id; openedUrl = url }
override fun closeWebView(id: String) { closedIds.add(id) }
override fun switchWebView(id: String) { switchedId = id }
override fun goBack(): Boolean { backCalls++; return false }
override fun goForward(): Boolean = false
override fun reload() {}
override fun syncTabs(tabs: List<Tab>) { syncedTabs = tabs }
}
class JsBridgeTest {
@Test
@@ -11,4 +38,156 @@ class JsBridgeTest {
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()
}
@Test
fun listTabs_withContainer_returnsJsonWithNameAndActive() {
val container = WebAppContainer()
container.open("a", "http://x/a", "云音乐")
val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true, webAppContainer = container)
val json = bridge.listTabs()
assertTrue(json.contains("\"id\":\"a\""))
assertTrue(json.contains("\"name\":\"云音乐\""))
assertTrue(json.contains("\"active\":true"))
}
@Test
fun listTabs_withoutContainer_returnsEmptyArray() {
val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true)
assertTrue(bridge.listTabs() == "[]")
}
@Test
fun openWebApp_findsUrlAndName_opensTabAndSyncs() {
val dir = File(System.getProperty("java.io.tmpdir"), "jb-open")
val http = object : HttpClient {
override fun get(url: String) = """{"apps":[{"id":"a","name":"云音乐","icon":"i","url":"http://x/a"}]}"""
}
val repo = WebAppRepository(http, CacheManager(dir), "http://fake")
val container = WebAppContainer()
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppRepository = repo, webAppContainer = container, webAppHost = host,
)
bridge.openWebApp("a")
assertEquals("a", host.openedId)
assertEquals("http://x/a", host.openedUrl)
assertEquals(1, container.tabs().size)
assertEquals("云音乐", container.tabs()[0].name)
assertEquals(1, host.syncedTabs?.size)
dir.deleteRecursively()
}
@Test
fun closeWebApp_destroysWebView_removesTab_andSyncs() {
val container = WebAppContainer()
container.open("a", "http://x/a", "云音乐")
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppContainer = container, webAppHost = host,
)
bridge.closeWebApp("a")
assertTrue(host.closedIds.contains("a"))
assertTrue(container.tabs().isEmpty())
assertEquals(0, host.syncedTabs?.size)
}
@Test
fun webGoBack_delegatesToHost() {
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppContainer = WebAppContainer(), webAppHost = host,
)
bridge.webGoBack()
assertEquals(1, host.backCalls)
}
@Test
fun openWebApp_defersViewOpsToMainThread() {
val dir = File(System.getProperty("java.io.tmpdir"), "jb-marshal")
val http = object : HttpClient {
override fun get(url: String) = """{"apps":[{"id":"a","name":"云音乐","icon":"i","url":"http://x/a"}]}"""
}
val repo = WebAppRepository(http, CacheManager(dir), "http://fake")
val container = WebAppContainer()
val host = FakeHost()
val posted = mutableListOf<() -> Unit>()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppRepository = repo, webAppContainer = container, webAppHost = host,
postToMainThread = { posted.add(it) },
)
bridge.openWebApp("a")
assertEquals(0, container.tabs().size)
assertEquals(null, host.openedId)
assertEquals(1, posted.size)
posted.forEach { it() }
assertEquals(1, container.tabs().size)
assertEquals("a", host.openedId)
assertEquals("http://x/a", host.openedUrl)
dir.deleteRecursively()
}
@Test
fun switchTab_unknownId_doesNotTouchHost() {
val container = WebAppContainer()
container.open("a", "http://x/a", "云音乐")
val host = FakeHost()
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppContainer = container, webAppHost = host,
)
bridge.switchTab("unknown")
assertEquals(null, host.switchedId)
assertEquals("a", container.activeTabId())
}
@Test
fun fetchWebApps_doesNotCacheEmptyManifest() {
val dir = File(System.getProperty("java.io.tmpdir"), "jb-empty-manifest")
var online = false
val http = object : HttpClient {
override fun get(url: String) =
if (online) """{"apps":[{"id":"a","name":"云音乐","icon":"i","url":"http://x/a"}]}""" else null
}
val repo = WebAppRepository(http, CacheManager(dir), "http://fake")
val bridge = JsBridge(
deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true,
webAppRepository = repo,
)
// 首次离线:返回空,且不得缓存空清单
// First call offline: empty result must not be cached
assertEquals("[]", bridge.fetchWebApps())
// 联网后再次拉取:不得命中空缓存,应返回应用
// Second call online: must not hit the empty cache, should return apps
online = true
val json = bridge.fetchWebApps()
assertTrue(json.contains("\"id\":\"a\""))
dir.deleteRecursively()
}
}
+122
View File
@@ -0,0 +1,122 @@
# Hearth 启动器 · 开发日志
> 项目:HearthAndroid 启动器)· 包名 `top.yeij.hearth`
> 日期:2026-08-16
## 1. 项目概述
面向瑞芯微 RK3566 横屏开发板(立创·泰山派)的 Android 启动器(Home)。
核心架构:**界面全 H5 渲染,原生只当「能力层」**,通过 JS Bridge`window.HearthBridge`)双向调用,桌面 UI、卡片、webAPP 均可远程下发、动态扩展,无需重装 APK。
目标平台:Android 11API 30)· 横屏 800×480 · 高 DPI(自适应系统 density)· `minSdk=30` 不做低版本兼容。
## 2. 环境搭建(arm64 工具链,本次最大障碍)
本机是 aarch64 容器,而 Android SDK 的构建工具(aapt2 等)官方只发布 x86_64 Linux 版。搭建过程:
| 组件 | 方案 | 来源 |
|---|---|---|
| JDK 17 | apt | 清华源 |
| Gradle 8.9 | 二进制包 | 腾讯镜像 |
| Android SDK platform-35 + build-tools 34.0.0 | 手动下载 | 腾讯镜像(dl.google.com 不通,且镜像无 android-34 正式版,故 `compileSdk=35` |
| **arm64 aapt2/zipalign/split-select** | drop-in 替换 | Commit451/android-arm-build-toolsgh-proxy.com 加速) |
| 依赖仓库 | 阿里云 google/central 镜像 | `maven.aliyun.com` |
**关键结论**:官方 Google Maven 无 arm64 Linux 版 aapt2Issue #227219818),只能社区构建。通过 `android.aapt2FromMavenOverride` 指向 arm64 aapt2 绕开 Google Maven 的 x86_64 下载。
另:AGP 首次构建会卡死在 `dl.google.com` 的 SDK 自动下载(SYN_SENT 挂起),需 `android.builder.sdkDownload=false` 关闭。
## 3. 设计决策
- **全 H5 渲染**:桌面 = 全屏 WebView + H5,原生只提供查应用/启动/媒体状态/下载缓存等能力。
- **UI 风格 Miuix / HyperOS 4 视觉**(纯 CSS 还原,非 Compose 库):
- **壁纸透明**Activity `showWallpaper` + WebView `setBackgroundColor(TRANSPARENT)` + H5 `html/body` 透明。
- **柔光玻璃**HyperOS 4 设计):卡片用 `backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1)` + 半透明背景 + 1px 描边(参数参考 Miuix `miuix-blur`saturation 1.2 / brightness +0.05 / contrast 1.1)。
- **卡片插件化**:每个卡片 = 独立 HTML + manifest(声明数据源/权限),可远程下发。
- **webAPP 多标签**:每个标签 = 一个独立 WebView(`Map<String, WebView>`),内容 WebView topMargin 44dp 露出 H5 顶栏。
- **媒体卡权限方案**`MEDIA_CONTENT_CONTROL` 是 signature|privileged 权限普通 APK 拿不到,改用 **NotificationListenerService**(用户授权"通知使用权"),`getActiveSessions(ComponentName)` 传 listener 组件作为授权凭据。
- **编译环境适配**`compileSdk=35` + AGP `8.6.1`(匹配 arm64 aapt2 8.6.x)。
## 4. 实现过程(14 个任务,TDD + 逐任务 review
| 阶段 | 任务 |
|---|---|
| 原生骨架 | 项目骨架+窗口管理 → WebViewManager+JsBridge → AppRepository |
| 桌面+列表 | H5 桌面壳 → 安卓APP列表 → CacheManager+WebApp清单 → H5应用列表 |
| 多标签 | WebAppContainer(标签状态数据层)→ 顶栏 H5 + tabs 纯函数 |
| 卡片系统 | CardRepository+首页三栏 → MediaSessionSource+媒体卡 |
| 收尾 | 设置页 → 集成收尾(接线+NotificationListenerService)→ 补充 webAPP 多标签 JS Bridge |
测试:Kotlin 单测 32 个(AppRepository/JsBridge/WebAppRepository/WebAppContainer/CacheManager 等)+ H5 node:test 8 个(cards.js 布局降级 / tabs.js 标签状态),全绿。
## 5. 关键问题与解决
1. **aapt2 arm64**:官方无 → Commit451 社区构建 drop-in 替换(见 §2)。
2. **JS 桥线程 vs 主线程**CriticalTask 14 review 发现):`@JavascriptInterface` 方法在 JavaBridge 后台线程运行,直接操作 View 会抛 `CalledFromWrongThreadException`。解决:所有 View 操作 `postToMainThread`(注入 `desktopWebView.post`marshal 到主线程。
3. **跨线程竞态**(最终 review 发现):WebAppContainer 的 `open/close/switchTo` 在主线程写、`listTabs` 在桥线程读同一 `mutableListOf`。解决:状态方法全部 `@Synchronized`
4. **innerHTML XSS**applist/webapplist/topbar 用 innerHTML 拼用户数据,桌面 WebView 持有桥权限可被注入。解决:全部改 `createElement` + `textContent`/`dataset`
5. **首页空卡片**`fetchCards` 在仓库未接线时返回 `"[]"``layout([])` 渲染空。解决:home.js 兜底补内置 time 卡。
6. **媒体卡权限**:见 §3NotificationListenerService 方案。
## 6. 遗留项(deferred,不影响 merge,后续处理)
- 真机验证:无 adb 设备,壁纸/柔光玻璃/媒体卡/多标签导航均未真机冒烟。
- `drawableToBase64` 未 recycle bitmap`timeTicker` setInterval 未清理。
- 沉浸模式仅在 onCreate 设置,返回前台未重设(建议 onResume)。
- 内容 WebView 无 onPause/onResume 生命周期同步。
- 设置页 Switch/Slider/版本号静态,交互未接。
- webAPP 服务器地址、卡片目录地址为占位常量,需真机配置。
- 二期:沉浸首页 freeform 小窗(需固件 `enable_freeform_support`)。
## 7. 分支与提交
开发走 `dev` 分支(`main` 为发布分支)。设计文档 `docs/superpowers/specs/`、实现计划 `docs/superpowers/plans/`、本日志 `docs/`
## 8. 真机调试与 UI 迭代(宿主机 OnePlus PLK110
### 8.1 真机环境
- 通过 SSH 连宿主机 Termux`aska@127.0.0.1:2022`),KernelSU root`logcat` 抓日志排查。
- APK 用容器内 Python `http.server` 分发(`http://localhost:8080/Hearth-debug.apk`)。
- 真机是 Android 16API 36)手机,非目标板(RK3566/Android 11),但能验证 H5 逻辑与大部分原生行为。
### 8.2 关键崩溃修复
1. **黑屏(实为崩溃)**`enterImmersive()``setContentView()` 之前调 `window.insetsController`,此时 DecorView 未创建(null)→ NPE。日志堆栈 `Attempt to invoke WindowInsetsController on a null object reference`。修复:把 `enterImmersive()` 移到 `setupWebView()`(内含 setContentView)之后。
2. **闪退**`MediaSessionSource` 未授权「通知使用权」时 `addOnActiveSessionsChangedListener``SecurityException: Missing permission to control media`。修复:`start()` 内 try-catch 降级 + 设置页加授权引导项(跳 `ACTION_NOTIFICATION_LISTENER_SETTINGS`)。
### 8.3 媒体卡完善
- **封面**`METADATA_KEY_ART` → 128px JPEG base64;加**封面缓存**(切歌瞬间 ART 短暂为 null,缓存兜底避免横划后封面消失)。
- **控制按钮**:上一首/暂停/下一首(`transportControls`),**按索引**作用于当前横划到的会话。
- **实时歌词**:网易云歌词实时更新在**通知标题**(不在 MediaSession metadata),`NotificationListenerService` 监听 `CATEGORY_TRANSPORT` 通知的 `EXTRA_TITLE`,标题优先用通知实时歌词。
- **进度条 + 时长 + 拖动 seek**:轮询兜底(registerCallback 在歌词场景不触发);进度条 `box-sizing:border-box``padding` 吃掉 `height` 导致 bar 高度 0 的坑;拖动 seek 调 `transportControls.seekTo`
- **多会话滑动**:左右横划切换(方向过渡动画),多个媒体通知(音乐/听书/视频)并存。
### 8.4 玻璃效果与壁纸
- **壁纸进 H5**`backdrop-filter` 只能模糊 WebView 内部内容,而壁纸原来在 WebView 之外(透明透出),故无模糊。原生 `getWallpaper()` 把壁纸转 base64 作 body 背景,卡片才能真正模糊壁纸。
- **动态壁纸降级**:检测 `wallpaperInfo != null` 返回空,H5 保持透明透出动态壁纸,玻璃模糊降级为普通半透明。
- **玻璃开关**:设置页开关,默认关(普通半透明),开 = 液态玻璃(磨砂模糊 + 边缘高光)。
### 8.5 设置页交互
主题三态(跟随/浅色/深色,`data-theme` 覆盖 `prefers-color-scheme`)、背景遮罩(夜间,开关+明暗度)、系统亮度(`WRITE_SETTINGS` 授权)、webAPP 服务器地址(SharedPreferences)、检查更新(重拉清单);滑块统一 touch 拖动。
### 8.6 webapp 多标签
- **原生标签面板**H5 面板是桌面 WebView 底层元素,被上层内容 WebView 遮住(Android View 层级无法用 z-index 跨越)。改 **PopupWindow 原生面板**覆盖显示,内容 WebView 只预留顶栏高度(不压 webapp)。
- **切页隐藏 webapp**`hideWebApps()`(内容 WebView GONE,标签保留)+ `body.webapp-active` 隐藏列表内容,切回自动恢复。
- **内容 WebView** 透明背景(未声明背景色的 webapp 透出壁纸)。
### 8.7 性能
- **页面预加载**:启动后后台并行拉取 APP 列表/webAPP 清单/卡片目录缓存,切页直接渲染。
- **媒体卡增量更新**:歌词实时变化时只更新媒体卡文字,不重建整个首页(之前每次歌词变化都重建导致卡顿)。
## 9. 仍在遗留
- 真机为手机(Android 16),目标板 RK3566/Android 11 尚未验证(壁纸/玻璃/沉浸式/媒体卡的板子适配)。
- 沉浸模式未在 onResume 重设;内容 WebView 无 onPause/onResume 同步。
- 二期:沉浸首页 freeform 小窗(需固件 `enable_freeform_support`)。
- webAPP/卡片目录默认占位地址,真机部署时配置真实服务器。
@@ -16,7 +16,8 @@
- aapt2 用 arm64 版:`gradle.properties``android.aapt2FromMavenOverride=/home/Aska/Android/Sdk/build-tools/34.0.0/aapt2`
- 横屏锁定 `screenOrientation = landscape`
- 依赖仅限:OkHttp、Gson(原生);H5 零依赖
- Miuix 视觉:深色背景 `#000`卡片 `#151518`、强调色 `#ff6900`;侧边栏收起 80px / 展开 240px
- Miuix 视觉:卡片 `#151518`、强调色 `#ff6900`;侧边栏收起 80px / 展开 240px
- 壁纸透明 + 柔光玻璃:WebView `setBackgroundColor(TRANSPARENT)` + Activity `showWallpaper` + H5 `html/body` 透明;卡片/侧边栏用半透明 `rgba(21,21,24,0.55)`(深)/ `rgba(255,255,255,0.4)`(浅)+ `backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1)` 柔光玻璃,1px 半透明描边;旧 WebView 不支持时降级纯半透明背景
- 夜间模式跟随系统 `prefers-color-scheme`
- 首页无应用入口(无 Dock),应用只从列表页打开
- Back 键桌面态屏蔽;webAPP 打开时 Back 先回退标签页
@@ -225,7 +225,9 @@ cards/<card-id>/
### 7.1 视觉规范(Miuix / HyperOS 风格,CSS 还原)
- **配色**:双色 token(CSS 变量),浅色/深色两套;深色模式背景纯黑 `#000`,卡片 `#151518`
- **配色**:双色 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`(选中态/进度条)。
@@ -265,13 +267,14 @@ cards/<card-id>/
- **横屏锁定**`screenOrientation = landscape`
- **默认桌面**Manifest 声明 `HOME` + `DEFAULT` intent-filter,用户手动设为默认。
- **Back 键屏蔽**:桌面状态下拦截 Back 键(按返回无响应);webAPP 打开时 Back 先作用于标签页回退,无历史可回退则关闭标签。
- **壁纸显示**Activity 窗口开启壁纸(theme `showWallpaper` / `WallpaperManager`),WebView `setBackgroundColor(TRANSPARENT)`,配合 H5 页面透明透出壁纸。
## 9. 权限模型
| 权限 | 用途 | 授予方式 |
|---|---|---|
| `QUERY_ALL_PACKAGES` | 查询已装应用列表 | launcher 豁免,声明即生效 |
| `MEDIA_CONTENT_CONTROL` | 读取媒体状态 | 运行时 + 通知监听授权(用户手动) |
| `BIND_NOTIFICATION_LISTENER_SERVICE` | 读取媒体通知(媒体卡数据源) | 用户手动授权「通知使用权」 |
| `READ_CALENDAR` | 日历卡片数据 | 运行时授权 |
| `INTERNET` | 拉取 webAPP/卡片 | 声明即生效 |
+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, '原数组不应被改动');
});