Compare commits

..

3 Commits

10 changed files with 317 additions and 19 deletions
+9 -1
View File
@@ -1,7 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
<application
android:label="Hearth"
android:theme="@android:style/Theme.Material.NoActionBar">
@@ -16,5 +15,14 @@
<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>
+106
View File
@@ -310,3 +310,109 @@ body {
}
#tab-panel .tab-close:hover { opacity: 1; }
/* 设置页:Miuix Preference 分组 + 柔光玻璃条目 */
/* Settings page: Miuix Preference groups + soft-glass items */
.st-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
/* 分组标题:小号弱化文本 */
/* Group header: small dim text */
.st-group {
margin: 8px 4px 6px;
font-size: 13px;
color: var(--text-dim);
}
/* 设置项:玻璃拟态圆角行(复用 Task 4 token,非纯色背景) */
/* Setting item: soft-glass rounded row (reuse Task 4 tokens, not solid color) */
.st-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
margin-bottom: 8px;
border-radius: 14px;
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
-webkit-backdrop-filter: blur(20px) saturate(1.2) brightness(1.05) contrast(1.1);
border: 1px solid var(--glass-border);
color: var(--text);
}
.st-item .lbl { font-size: 15px; }
/* 右侧箭头:弱化色 */
/* Trailing arrow: dim color */
.st-item .arrow {
color: var(--text-dim);
font-size: 18px;
line-height: 1;
}
/* 开关:Miuix Switch(静态展示,交互留后续) */
/* Switch: Miuix style (static; interaction later) */
.sw {
position: relative;
flex: none;
width: 46px;
height: 28px;
border-radius: 999px;
background: rgba(127, 127, 127, 0.35);
transition: background .2s;
}
.sw.on { background: var(--accent); }
.sw i {
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: left .2s;
}
.sw.on i { left: 21px; }
/* 滑块:Miuix Slider(静态展示:轨道 + 高亮填充 + 圆点) */
/* Slider: Miuix style (static: track + filled portion + thumb) */
.slider {
position: relative;
flex: none;
width: 140px;
height: 4px;
border-radius: 999px;
background: var(--glass-border);
}
.slider::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 60%;
border-radius: 999px;
background: var(--accent);
}
.slider i {
position: absolute;
top: 50%;
left: 60%;
transform: translate(-50%, -50%);
width: 18px;
height: 18px;
border-radius: 999px;
background: var(--accent);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
+1
View File
@@ -27,6 +27,7 @@
<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>
@@ -0,0 +1,16 @@
// 设置页:Miuix Preference 分组 + 玻璃条目(仅 UI 骨架,交互留后续)
// Settings page: Miuix Preference groups + glass items (UI skeleton only; interactions later)
window.loadSettings = function () {
const page = document.querySelector('[data-page="settings"]');
if (page.dataset.loaded) return; page.dataset.loaded = '1';
page.innerHTML = `<div class="st-wrap">
<div class="st-group">显示</div>
<div class="st-item"><span class="lbl">跟随系统主题</span><span class="sw on"><i></i></span></div>
<div class="st-item"><span class="lbl">屏幕亮度</span><span class="slider"><i></i></span></div>
<div class="st-group">网络</div>
<div class="st-item"><span class="lbl">webAPP 服务器地址</span><span class="arrow"></span></div>
<div class="st-item"><span class="lbl">检查更新</span><span class="arrow"></span></div>
<div class="st-group">关于</div>
<div class="st-item"><span class="lbl">版本 0.1.0</span><span class="arrow"></span></div>
</div>`;
};
+1
View File
@@ -21,5 +21,6 @@ const router = {
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();
}
};
@@ -3,44 +3,90 @@ package top.yeij.hearth
import android.app.Activity
import android.content.res.Configuration
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.app.NotificationManagerCompat
import top.yeij.hearth.app.AndroidAppSource
import top.yeij.hearth.app.AppRepository
import top.yeij.hearth.cache.CacheManager
import top.yeij.hearth.card.CardRepository
import top.yeij.hearth.media.MediaSessionSource
import top.yeij.hearth.webapp.OkHttpHttpClient
import top.yeij.hearth.webapp.WebAppContainer
import top.yeij.hearth.webapp.WebAppRepository
import top.yeij.hearth.webview.JsBridge
import top.yeij.hearth.webview.WebViewManager
import java.io.File
// HOME 桌面 activity后续 Task 挂载 WebView 与 JsBridge
// HOME launcher activity; later tasks mount WebView and JsBridge
// HOME 桌面 activity接线各能力层:Repository、媒体监听、WebView 错误页与 Back 键
// HOME launcher activity, wiring all capability layers: repositories, media listener,
// WebView error page, and Back key handling
class MainActivity : Activity() {
companion object {
private const val TAG = "HearthMainActivity"
// 占位清单/目录地址,真机部署时可替换为实际服务器
// Placeholder manifest/catalog URLs; replace with the real server on device
private const val MANIFEST_URL = "https://example.com/hearth/manifest.json"
private const val CATALOG_URL = "https://example.com/hearth/catalog.json"
}
private val webAppContainer = WebAppContainer()
private lateinit var mediaSource: MediaSessionSource
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate: enter immersive mode")
enterImmersive()
setupWebView()
checkNotificationAccess()
}
// 创建 JsBridge 并挂载 WebView 桌面层
// Create JsBridge and mount the WebView launcher layer
override fun onDestroy() {
mediaSource.stop()
super.onDestroy()
}
// 创建 JsBridge 并挂载 WebView 桌面层,接线所有 Repository 与媒体监听
// Create JsBridge and mount the WebView launcher layer, wiring all repositories
// and the media session listener
private fun setupWebView() {
val dm = resources.displayMetrics
val darkMode =
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES
val cache = CacheManager(File(filesDir, "cache"))
val http = OkHttpHttpClient()
val bridge = JsBridge(
deviceWidthPx = dm.widthPixels,
deviceHeightPx = dm.heightPixels,
density = dm.density,
darkMode = darkMode,
appRepository = AppRepository(AndroidAppSource(this)),
webAppRepository = WebAppRepository(http, cache, MANIFEST_URL),
cardRepository = CardRepository(http, cache, CATALOG_URL),
)
WebViewManager(this).attach(bridge)
val webView = WebViewManager(this).attach(bridge)
webView.webViewClient = object : WebViewClient() {
// 主帧加载失败时展示错误页,避免白屏
// Show an error page on main-frame load failure to avoid a blank screen
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onReceivedError(
view: WebView?,
code: Int,
description: String?,
failingUrl: String?,
) {
Log.d(TAG, "onReceivedError: code=$code desc=$description url=$failingUrl")
view?.loadDataWithBaseURL(null, errorPage(description ?: "未知错误"), "text/html", "utf-8", null)
}
}
mediaSource = MediaSessionSource(this)
bridge.setMediaListener(mediaSource, webView)
Log.d(
TAG,
"setupWebView: attached WebView ${dm.widthPixels}x${dm.heightPixels}" +
@@ -48,6 +94,36 @@ class MainActivity : Activity() {
)
}
// 检测通知使用权是否已授予,未授予时打日志提示(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() {
@@ -58,11 +134,24 @@ 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 tabs = webAppContainer.tabs()
if (tabs.isNotEmpty()) {
val active = tabs.firstOrNull { it.active }
if (active != null) {
// goBack() 当前为占位 false,实际回退逻辑留 WebView 导航接入
// goBack() is a placeholder returning false; real navigation lands later
if (!webAppContainer.goBack()) {
webAppContainer.close(active.id)
}
}
Log.d(TAG, "onBackPressed: webAPP back/close, remaining tabs=${webAppContainer.tabs().size}")
return
}
Log.d(TAG, "onBackPressed: desktop state, swallowed")
}
}
@@ -0,0 +1,13 @@
package top.yeij.hearth.media
import android.service.notification.NotificationListenerService
// 通知监听服务:空实现,仅作为「通知使用权」授权凭据,
// 让 MediaSessionManager.addOnActiveSessionsChangedListener(cb, listenerComponent)
// 能拿到活跃媒体会话(否则普通 APK 拿不到 MEDIA_CONTENT_CONTROL 系统权限)。
// Notification listener service: empty implementation, only serves as the
// "notification access" authorization credential so that
// MediaSessionManager.addOnActiveSessionsChangedListener(cb, listenerComponent)
// can receive active media sessions (a normal APK cannot hold the
// MEDIA_CONTENT_CONTROL system permission).
class HearthNotificationListenerService : NotificationListenerService()
@@ -1,5 +1,6 @@
package top.yeij.hearth.media
import android.content.ComponentName
import android.content.Context
import android.media.MediaMetadata
import android.media.session.MediaController
@@ -11,16 +12,26 @@ import android.util.Log
// Watch active media sessions; build MediaInfo from the first controller's metadata + playbackState
class MediaSessionSource(context: Context) {
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
private var listener: ((MediaInfo?) -> Unit)? = null
fun start(cb: (MediaInfo?) -> Unit) {
listener = cb
msm.addOnActiveSessionsChangedListener({ controllers ->
// 通知监听组件作为授权凭据传入 addOnActiveSessionsChangedListener
// 取代依赖 MEDIA_CONTENT_CONTROL 系统权限的 null 方案。
// The notification listener component is passed as the authorization credential,
// replacing the null-based approach that relied on the MEDIA_CONTENT_CONTROL permission.
private val listenerComponent = ComponentName(context, HearthNotificationListenerService::class.java)
private var callback: ((MediaInfo?) -> Unit)? = null
private var registered = false
// 具名监听器字段:回调通过 callback 字段转发,便于 start/stop 幂等管理
// Named listener field: forwards via callback, enabling idempotent start/stop
private val activeSessionsListener =
MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
val cb = callback ?: return@OnActiveSessionsChangedListener
val c = controllers?.firstOrNull()
if (c == null) {
Log.d("HearthMedia", "no active media session")
Log.d(TAG, "no active media session")
cb(null)
return@addOnActiveSessionsChangedListener
return@OnActiveSessionsChangedListener
}
val meta = c.metadata
val state = c.playbackState
@@ -35,7 +46,32 @@ class MediaSessionSource(context: Context) {
c.packageName,
)
)
Log.d("HearthMedia", "media: ${c.packageName}")
}, null)
Log.d(TAG, "media: ${c.packageName}")
}
// 注册监听:幂等——先移除旧监听再注册,避免重复 start 累积监听器
// Register the listener: idempotent — remove the old listener first, avoiding
// listener accumulation from repeated start() calls
fun start(cb: (MediaInfo?) -> Unit) {
stop()
callback = cb
msm.addOnActiveSessionsChangedListener(activeSessionsListener, listenerComponent)
registered = true
Log.d(TAG, "start: media session listener registered")
}
// 注销监听:释放回调引用,供 Activity onDestroy 调用
// Unregister the listener: release the callback, call from Activity onDestroy
fun stop() {
if (registered) {
msm.removeOnActiveSessionsChangedListener(activeSessionsListener)
registered = false
Log.d(TAG, "stop: media session listener removed")
}
callback = null
}
companion object {
private const val TAG = "HearthMedia"
}
}
@@ -0,0 +1,28 @@
package top.yeij.hearth.webapp
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
// 基于 OkHttp 的 HttpClient 实现:GET 返回响应体字符串,失败/非 2xx 返回 null
// OkHttp-backed HttpClient: GET returns the response body string, null on failure or non-2xx
class OkHttpHttpClient(
private val client: OkHttpClient = OkHttpClient(),
) : HttpClient {
override fun get(url: String): String? {
return try {
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { resp ->
if (resp.isSuccessful) resp.body?.string() else null
}
} catch (e: IOException) {
Log.d(TAG, "get failed: $url -> ${e.message}")
null
}
}
companion object {
private const val TAG = "HearthHttp"
}
}
@@ -274,7 +274,7 @@ cards/<card-id>/
| 权限 | 用途 | 授予方式 |
|---|---|---|
| `QUERY_ALL_PACKAGES` | 查询已装应用列表 | launcher 豁免,声明即生效 |
| `MEDIA_CONTENT_CONTROL` | 读取媒体状态 | 运行时 + 通知监听授权(用户手动) |
| `BIND_NOTIFICATION_LISTENER_SERVICE` | 读取媒体通知(媒体卡数据源) | 用户手动授权「通知使用权」 |
| `READ_CALENDAR` | 日历卡片数据 | 运行时授权 |
| `INTERNET` | 拉取 webAPP/卡片 | 声明即生效 |