feat: notification listener service and media source hardening

This commit is contained in:
2026-08-16 17:02:11 +08:00
parent 05a4009532
commit abed4f4e15
3 changed files with 66 additions and 9 deletions
+9 -1
View File
@@ -1,7 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
<application <application
android:label="Hearth" android:label="Hearth"
android:theme="@android:style/Theme.Material.NoActionBar"> android:theme="@android:style/Theme.Material.NoActionBar">
@@ -16,5 +15,14 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>
@@ -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 package top.yeij.hearth.media
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.media.MediaMetadata import android.media.MediaMetadata
import android.media.session.MediaController 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 // Watch active media sessions; build MediaInfo from the first controller's metadata + playbackState
class MediaSessionSource(context: Context) { class MediaSessionSource(context: Context) {
private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
private var listener: ((MediaInfo?) -> Unit)? = null
fun start(cb: (MediaInfo?) -> Unit) { // 通知监听组件作为授权凭据传入 addOnActiveSessionsChangedListener
listener = cb // 取代依赖 MEDIA_CONTENT_CONTROL 系统权限的 null 方案。
msm.addOnActiveSessionsChangedListener({ controllers -> // 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() val c = controllers?.firstOrNull()
if (c == null) { if (c == null) {
Log.d("HearthMedia", "no active media session") Log.d(TAG, "no active media session")
cb(null) cb(null)
return@addOnActiveSessionsChangedListener return@OnActiveSessionsChangedListener
} }
val meta = c.metadata val meta = c.metadata
val state = c.playbackState val state = c.playbackState
@@ -35,7 +46,32 @@ class MediaSessionSource(context: Context) {
c.packageName, c.packageName,
) )
) )
Log.d("HearthMedia", "media: ${c.packageName}") Log.d(TAG, "media: ${c.packageName}")
}, null) }
// 注册监听:幂等——先移除旧监听再注册,避免重复 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"
} }
} }