Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,31 @@
- 🔋 **电池追踪** - 实时获取设备电量百分比和充电状态
- ⚙️ **灵活配置** - 用户友好的设置界面,支持自定义服务器
- 📱 **应用信息** - 获取并显示当前前台应用的详细信息
- 🎵 **媒体状态上报** - 可选功能,将当前播放的媒体(标题/艺术家)作为独立设备上报,可自定义设备 ID 与显示名称
- 📝 **日志系统** - 内置多级别日志,便于调试和问题排查

## 🎵 媒体状态上报

在主界面的「媒体状态上报」区域可以:

1. 开关此功能(默认关闭)
2. 自定义媒体状态上报使用的**设备 ID** 和**显示名称**(与常规设备状态相互独立,可在 Sleepy 页面上显示为单独的一项)
3. 选择获取媒体播放状态的方式:

| 方式 | 说明 | 是否需要额外授权 |
| ------------------ | -------------------------------------------------------------------- | ----------------- |
| **自动(推荐)** | 根据检测到的 Android 版本和系统 UI(ROM)厂商,自动在下面两种方式中选择最合适的一种 | 视结果而定 |
| **系统钩子** | 通过 Xposed 钩子在 system_server 进程内直接调用 `MediaSessionManager` 获取,零配置 | 否 |
| **通知监听** | 使用标准 `NotificationListenerService` API,在应用自身进程内运行,跨 ROM 兼容性最好 | 是(需在设置中手动授权一次,可点击应用内按钮直达) |
| **Dumpsys Shell** | 通过解析 `dumpsys media_session` 命令输出获取,思路参考自 Magisk 版 Sleepy 脚本,作为兼容性/调试备用方案 | 否(复用系统钩子权限) |

选择「自动」时,模块会检测当前设备是否为 MIUI/HyperOS、ColorOS、FuntouchOS/OriginOS、EMUI/MagicUI、One UI、Flyme 等定制系统:

- 接近原生 / AOSP 的系统 → 推荐**系统钩子**(无需额外操作)
- 检测到重度定制系统 → 推荐**通知监听**(这类系统通常对后台/系统级调用有更多限制,通知监听是官方标准方案,兼容性更好)

如果选择「通知监听」方式,请点击应用内的「授予通知访问权限」按钮,在系统设置中为 SleepyXposed 开启通知访问权限后功能才会生效。

## TODO

- [ ] 优化 UI 交互
Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ android {

defaultConfig {
applicationId = "com.rhencloud.sleepyxposed"
minSdk = 26
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
Expand Down
24 changes: 24 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,30 @@
</intent-filter>
</activity>

<!-- Notification listener used for the media-status "Notification Listener" method -->
<service
android:name=".MediaListenerService"
android:label="@string/media_listener_label"
android:exported="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>

<!--
Exposes config to system_server. Private SharedPreferences are SELinux-blocked from
system_server under modern ROMs / libxposed API 101; the provider is queried with the
module app identity so prefs load correctly. Only system UID may read (enforced in code).
-->
<provider
android:name=".ConfigContentProvider"
android:authorities="com.rhencloud.sleepyxposed.config"
android:exported="true"
android:enabled="true"
android:directBootAware="true"
android:grantUriPermissions="false" />

<!-- Xposed Module Metadata -->
<meta-data
android:name="xposedmodule"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.rhencloud.sleepyxposed

import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Binder
import android.os.Process

/**
* Exports module configuration to the system_server process.
*
* Modern Android SELinux blocks system_server from reading another app's private
* [android.content.SharedPreferences] / data dirs. Legacy [de.robv.android.xposed.XSharedPreferences]
* also often fails under libxposed API 101. A ContentProvider query from the system UID works
* because the framework starts this app process and reads prefs with the app's own identity.
*
* Only the system UID (and this app) may query; other callers are rejected.
*/
class ConfigContentProvider : ContentProvider() {

override fun onCreate(): Boolean = true

override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? {
enforceSystemOrSelf()
val ctx = context ?: return null
val config = ConfigManager.loadConfig(ctx)
val cursor = MatrixCursor(COLUMNS)
cursor.addRow(
arrayOf(
config.serverUrl,
config.secret,
config.deviceId,
config.showName,
if (config.enabled) 1 else 0,
if (config.mediaEnabled) 1 else 0,
config.mediaDeviceId,
config.mediaShowName,
config.mediaMethod
)
)
return cursor
}

override fun getType(uri: Uri): String = "vnd.android.cursor.item/vnd.$AUTHORITY.config"

override fun insert(uri: Uri, values: ContentValues?): Uri? = null

override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0

override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = 0

private fun enforceSystemOrSelf() {
val uid = Binder.getCallingUid()
if (uid != Process.SYSTEM_UID && uid != Process.myUid() && uid != 0) {
throw SecurityException("SleepyXposed config is only readable by system")
}
}

companion object {
const val AUTHORITY = "com.rhencloud.sleepyxposed.config"
val CONTENT_URI: Uri = Uri.parse("content://$AUTHORITY/config")

val COLUMNS =
arrayOf(
"server_url",
"secret",
"device_id",
"show_name",
"enabled",
"media_enabled",
"media_device_id",
"media_show_name",
"media_method"
)
}
}
Loading
Loading