Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 381b9a57e7 | |||
| abed4f4e15 | |||
| 05a4009532 |
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
};
|
||||
@@ -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/卡片 | 声明即生效 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user