diff --git a/README.md b/README.md
index a601e2f..be3cde7 100644
--- a/README.md
+++ b/README.md
@@ -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 交互
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b587adf..86b7ed0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -18,7 +18,7 @@ android {
defaultConfig {
applicationId = "com.rhencloud.sleepyxposed"
- minSdk = 26
+ minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5f74ed8..b2a7a5e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -26,6 +26,30 @@
+
+
+
+
+
+
+
+
+
+
?,
+ selection: String?,
+ selectionArgs: Array?,
+ 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?): Int = 0
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?
+ ): 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"
+ )
+ }
+}
diff --git a/app/src/main/java/com/rhencloud/sleepyxposed/ConfigManager.kt b/app/src/main/java/com/rhencloud/sleepyxposed/ConfigManager.kt
index a053df6..a191cd5 100644
--- a/app/src/main/java/com/rhencloud/sleepyxposed/ConfigManager.kt
+++ b/app/src/main/java/com/rhencloud/sleepyxposed/ConfigManager.kt
@@ -11,10 +11,18 @@ data class SleepyConfig(
val secret: String = "",
val deviceId: String = "",
val showName: String = "",
- val enabled: Boolean = false
+ val enabled: Boolean = false,
+ /** Whether media playback status reporting is enabled. */
+ val mediaEnabled: Boolean = false,
+ /** Device ID used when reporting media playback status (independent of [deviceId]). */
+ val mediaDeviceId: String = "",
+ /** Display name used when reporting media playback status. */
+ val mediaShowName: String = "",
+ /** Name of the [MediaMethod] used to acquire media playback status. */
+ val mediaMethod: String = MediaMethod.AUTO.name
)
-/** Configuration manager for loading and saving config.json */
+/** Configuration manager for loading and saving config across app + system_server. */
object ConfigManager {
private const val PREF_FILE_NAME = "sleepy_config"
private const val MODULE_PACKAGE_NAME = "com.rhencloud.sleepyxposed"
@@ -23,28 +31,119 @@ object ConfigManager {
private const val KEY_DEVICE_ID = "device_id"
private const val KEY_SHOW_NAME = "show_name"
private const val KEY_ENABLED = "enabled"
+ private const val KEY_MEDIA_ENABLED = "media_enabled"
+ private const val KEY_MEDIA_DEVICE_ID = "media_device_id"
+ private const val KEY_MEDIA_SHOW_NAME = "media_show_name"
+ private const val KEY_MEDIA_METHOD = "media_method"
private const val FALLBACK_DIR = "SleepyXposed"
private const val FALLBACK_FILE_NAME = "config.json"
/** Load configuration for module app process */
fun loadConfig(context: Context): SleepyConfig {
- val prefContext = getPrefContext(context)
- @Suppress("DEPRECATION")
- val pref = prefContext.getSharedPreferences(PREF_FILE_NAME, Context.MODE_WORLD_READABLE)
- val config =
- SleepyConfig(
- serverUrl = pref.getString(KEY_SERVER_URL, "") ?: "",
- secret = pref.getString(KEY_SECRET, "") ?: "",
- deviceId = pref.getString(KEY_DEVICE_ID, "") ?: "",
- showName = pref.getString(KEY_SHOW_NAME, "") ?: "",
- enabled = pref.getBoolean(KEY_ENABLED, false)
- )
+ return try {
+ val de = readConfigFromPrefs(getDeviceProtectedContext(context), requireComplete = false)
+ if (de != null && de.hasRequiredFields()) return de
+
+ val ce = readConfigFromPrefs(context, requireComplete = false)
+ if (ce != null && ce.hasRequiredFields()) return ce
+
+ loadConfigFromJsonFiles(context)
+ ?: de
+ ?: ce
+ ?: SleepyConfig()
+ } catch (_: Exception) {
+ loadConfigFromJsonFiles(context) ?: SleepyConfig()
+ }
+ }
+
+ /**
+ * Load configuration inside hooked processes (typically system_server).
+ *
+ * Private app data is SELinux-blocked from system_server on modern ROMs, and classic
+ * XSharedPreferences is unreliable under libxposed API 101. Prefer:
+ * 1. ContentProvider (app process reads its own prefs)
+ * 2. Public / media JSON files written on save
+ * 3. Legacy XSharedPreferences / prefs XML (best-effort)
+ */
+ fun loadConfigFromXSharedPreferences(systemContext: Context? = null): SleepyConfig {
+ systemContext?.let { ctx ->
+ loadViaContentProvider(ctx)?.takeIf { it.hasRequiredFields() }?.let {
+ return it
+ }
+ }
+
+ loadConfigFromJsonFiles(null)?.takeIf { it.hasRequiredFields() }?.let {
+ return it
+ }
+
+ loadViaLegacyXSharedPreferences()?.takeIf { it.hasRequiredFields() }?.let {
+ return it
+ }
- return if (config.hasRequiredFields()) config else loadConfigFromFallbackFile() ?: config
+ loadConfigFromPrefsXmlFiles()?.takeIf { it.hasRequiredFields() }?.let {
+ return it
+ }
+
+ return loadViaContentProvider(systemContext)
+ ?: loadConfigFromJsonFiles(null)
+ ?: loadViaLegacyXSharedPreferences()
+ ?: loadConfigFromPrefsXmlFiles()
+ ?: SleepyConfig()
+ }
+
+ /** Human-readable diagnostics for why system_server cannot see config. */
+ fun describeLoadSources(systemContext: Context?): String {
+ val parts = mutableListOf()
+ val provider = runCatching { loadViaContentProvider(systemContext) }.getOrNull()
+ parts.add(
+ "provider=${provider?.let { if (it.hasRequiredFields()) "ok" else "incomplete" } ?: "fail"}"
+ )
+ val json = runCatching { loadConfigFromJsonFiles(null) }.getOrNull()
+ parts.add("json=${json?.let { if (it.hasRequiredFields()) "ok" else "incomplete" } ?: "fail"}")
+ val xsp = runCatching { loadViaLegacyXSharedPreferences() }.getOrNull()
+ parts.add("xsp=${xsp?.let { if (it.hasRequiredFields()) "ok" else "incomplete" } ?: "fail"}")
+ val xml = runCatching { loadConfigFromPrefsXmlFiles() }.getOrNull()
+ parts.add("prefsXml=${xml?.let { if (it.hasRequiredFields()) "ok" else "incomplete" } ?: "fail"}")
+ val existing =
+ getAllJsonCandidates(null).filter { it.exists() }.joinToString(",") { it.absolutePath }
+ parts.add("jsonFiles=[${existing.ifBlank { "none" }}]")
+ return parts.joinToString("; ")
+ }
+
+ private fun loadViaContentProvider(context: Context?): SleepyConfig? {
+ if (context == null) return null
+ return try {
+ context.contentResolver
+ .query(ConfigContentProvider.CONTENT_URI, null, null, null, null)
+ ?.use { cursor ->
+ if (!cursor.moveToFirst()) return null
+ fun col(name: String): Int = cursor.getColumnIndex(name)
+ fun str(name: String): String {
+ val i = col(name)
+ return if (i >= 0 && !cursor.isNull(i)) cursor.getString(i) ?: "" else ""
+ }
+ fun bool(name: String): Boolean {
+ val i = col(name)
+ return if (i >= 0 && !cursor.isNull(i)) cursor.getInt(i) != 0 else false
+ }
+ SleepyConfig(
+ serverUrl = str("server_url"),
+ secret = str("secret"),
+ deviceId = str("device_id"),
+ showName = str("show_name"),
+ enabled = bool("enabled"),
+ mediaEnabled = bool("media_enabled"),
+ mediaDeviceId = str("media_device_id"),
+ mediaShowName = str("media_show_name"),
+ mediaMethod = str("media_method").ifBlank { MediaMethod.AUTO.name }
+ )
+ }
+ } catch (_: Exception) {
+ null
+ }
}
- /** Load configuration for hooked process via XSharedPreferences */
- fun loadConfigFromXSharedPreferences(): SleepyConfig {
+ private fun loadViaLegacyXSharedPreferences(): SleepyConfig? {
return try {
val clazz = Class.forName("de.robv.android.xposed.XSharedPreferences")
val constructor = clazz.getConstructor(String::class.java, String::class.java)
@@ -56,152 +155,275 @@ object ConfigManager {
val getBoolean =
clazz.getMethod("getBoolean", String::class.java, Boolean::class.javaPrimitiveType)
- val config =
- SleepyConfig(
- serverUrl = (getString.invoke(pref, KEY_SERVER_URL, "") as? String) ?: "",
- secret = (getString.invoke(pref, KEY_SECRET, "") as? String) ?: "",
- deviceId = (getString.invoke(pref, KEY_DEVICE_ID, "") as? String) ?: "",
- showName = (getString.invoke(pref, KEY_SHOW_NAME, "") as? String) ?: "",
- enabled = (getBoolean.invoke(pref, KEY_ENABLED, false) as? Boolean) ?: false
+ SleepyConfig(
+ serverUrl = (getString.invoke(pref, KEY_SERVER_URL, "") as? String) ?: "",
+ secret = (getString.invoke(pref, KEY_SECRET, "") as? String) ?: "",
+ deviceId = (getString.invoke(pref, KEY_DEVICE_ID, "") as? String) ?: "",
+ showName = (getString.invoke(pref, KEY_SHOW_NAME, "") as? String) ?: "",
+ enabled = (getBoolean.invoke(pref, KEY_ENABLED, false) as? Boolean) ?: false,
+ mediaEnabled =
+ (getBoolean.invoke(pref, KEY_MEDIA_ENABLED, false) as? Boolean) ?: false,
+ mediaDeviceId = (getString.invoke(pref, KEY_MEDIA_DEVICE_ID, "") as? String) ?: "",
+ mediaShowName = (getString.invoke(pref, KEY_MEDIA_SHOW_NAME, "") as? String) ?: "",
+ mediaMethod =
+ (getString.invoke(pref, KEY_MEDIA_METHOD, MediaMethod.AUTO.name) as? String)
+ ?: MediaMethod.AUTO.name
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun loadConfigFromPrefsXmlFiles(): SleepyConfig? {
+ for (file in getModulePrefsXmlCandidates()) {
+ try {
+ if (!file.exists() || !file.canRead()) continue
+ parseSharedPreferencesXml(file.readText())?.let { config ->
+ if (config.hasRequiredFields() || config.mediaEnabled) {
+ return config
+ }
+ }
+ } catch (_: Exception) {}
+ }
+ return null
+ }
+
+ private fun getModulePrefsXmlCandidates(): List {
+ val fileName = "$PREF_FILE_NAME.xml"
+ return listOf(
+ File("/data/user_de/0/$MODULE_PACKAGE_NAME/shared_prefs/$fileName"),
+ File("/data/user/0/$MODULE_PACKAGE_NAME/shared_prefs/$fileName"),
+ File("/data/data/$MODULE_PACKAGE_NAME/shared_prefs/$fileName")
+ )
+ }
+
+ private fun parseSharedPreferencesXml(xml: String): SleepyConfig? {
+ if (!xml.contains("""",
+ setOf(RegexOption.DOT_MATCHES_ALL)
)
+ val raw = re.find(xml)?.groupValues?.getOrNull(1) ?: return ""
+ return raw
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("&", "&")
+ .replace(""", "\"")
+ .replace("'", "'")
+ }
- if (config.hasRequiredFields()) {
- config
- } else {
- loadConfigFromFallbackFile() ?: config
- }
- } catch (e: Exception) {
- loadConfigFromFallbackFile() ?: SleepyConfig()
+ fun booleanValue(key: String, default: Boolean = false): Boolean {
+ val re = Regex("""""")
+ return re.find(xml)?.groupValues?.getOrNull(1)?.toBoolean() ?: default
}
+
+ return SleepyConfig(
+ serverUrl = stringValue(KEY_SERVER_URL),
+ secret = stringValue(KEY_SECRET),
+ deviceId = stringValue(KEY_DEVICE_ID),
+ showName = stringValue(KEY_SHOW_NAME),
+ enabled = booleanValue(KEY_ENABLED, false),
+ mediaEnabled = booleanValue(KEY_MEDIA_ENABLED, false),
+ mediaDeviceId = stringValue(KEY_MEDIA_DEVICE_ID),
+ mediaShowName = stringValue(KEY_MEDIA_SHOW_NAME),
+ mediaMethod = stringValue(KEY_MEDIA_METHOD).ifBlank { MediaMethod.AUTO.name }
+ )
}
/** Save configuration in module app process */
fun saveConfig(context: Context, config: SleepyConfig): Boolean {
+ val deSaved = writeConfigToPrefs(getDeviceProtectedContext(context), config)
+ val ceSaved = writeConfigToPrefs(context, config)
+ // JSON mirrors for system_server (private app data is SELinux-blocked from system).
+ val jsonSaved = saveConfigToJsonFiles(context, config)
+
+ try {
+ context.contentResolver.notifyChange(ConfigContentProvider.CONTENT_URI, null)
+ } catch (_: Exception) {}
+
+ return deSaved || ceSaved || jsonSaved
+ }
+
+ private fun readConfigFromPrefs(
+ context: Context,
+ requireComplete: Boolean = true
+ ): SleepyConfig? {
return try {
- val prefContext = getPrefContext(context)
- @Suppress("DEPRECATION")
- val pref = prefContext.getSharedPreferences(PREF_FILE_NAME, Context.MODE_WORLD_READABLE)
- val prefSaved =
+ val pref = context.getSharedPreferences(PREF_FILE_NAME, Context.MODE_PRIVATE)
+ if (!pref.contains(KEY_SERVER_URL) &&
+ !pref.contains(KEY_SECRET) &&
+ !pref.contains(KEY_DEVICE_ID) &&
+ !pref.contains(KEY_SHOW_NAME) &&
+ !pref.contains(KEY_MEDIA_ENABLED)
+ ) {
+ return null
+ }
+
+ val config =
+ SleepyConfig(
+ serverUrl = pref.getString(KEY_SERVER_URL, "") ?: "",
+ secret = pref.getString(KEY_SECRET, "") ?: "",
+ deviceId = pref.getString(KEY_DEVICE_ID, "") ?: "",
+ showName = pref.getString(KEY_SHOW_NAME, "") ?: "",
+ enabled = pref.getBoolean(KEY_ENABLED, false),
+ mediaEnabled = pref.getBoolean(KEY_MEDIA_ENABLED, false),
+ mediaDeviceId = pref.getString(KEY_MEDIA_DEVICE_ID, "") ?: "",
+ mediaShowName = pref.getString(KEY_MEDIA_SHOW_NAME, "") ?: "",
+ mediaMethod =
+ pref.getString(KEY_MEDIA_METHOD, MediaMethod.AUTO.name)
+ ?: MediaMethod.AUTO.name
+ )
+ if (requireComplete && !config.hasRequiredFields()) null else config
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun writeConfigToPrefs(context: Context, config: SleepyConfig): Boolean {
+ return try {
+ val pref = context.getSharedPreferences(PREF_FILE_NAME, Context.MODE_PRIVATE)
+ val saved =
pref.edit()
.putString(KEY_SERVER_URL, config.serverUrl)
.putString(KEY_SECRET, config.secret)
.putString(KEY_DEVICE_ID, config.deviceId)
.putString(KEY_SHOW_NAME, config.showName)
.putBoolean(KEY_ENABLED, config.enabled)
+ .putBoolean(KEY_MEDIA_ENABLED, config.mediaEnabled)
+ .putString(KEY_MEDIA_DEVICE_ID, config.mediaDeviceId)
+ .putString(KEY_MEDIA_SHOW_NAME, config.mediaShowName)
+ .putString(KEY_MEDIA_METHOD, config.mediaMethod)
.commit()
-
- makePrefsWorldReadable(prefContext)
-
- val fallbackSaved = saveConfigToFallbackFile(context, config)
- prefSaved || fallbackSaved
- } catch (e: Exception) {
- saveConfigToFallbackFile(context, config)
+ makePrefsWorldReadable(context)
+ saved
+ } catch (_: Exception) {
+ false
}
}
- /** Get preference XML path for debugging */
fun getConfigFilePath(context: Context): String {
- return getAppFallbackConfigFile(context).absolutePath
+ return getPrimaryPublicConfigFile().absolutePath
}
- private fun saveConfigToFallbackFile(context: Context, config: SleepyConfig): Boolean {
- return try {
- val file = getAppFallbackConfigFile(context)
- val parent = file.parentFile
- if (parent != null && !parent.exists()) {
- parent.mkdirs()
- }
-
- val json =
- JSONObject().apply {
- put(KEY_SERVER_URL, config.serverUrl)
- put(KEY_SECRET, config.secret)
- put(KEY_DEVICE_ID, config.deviceId)
- put(KEY_SHOW_NAME, config.showName)
- put(KEY_ENABLED, config.enabled)
- }
+ private fun configToJson(config: SleepyConfig): String {
+ return JSONObject()
+ .apply {
+ put(KEY_SERVER_URL, config.serverUrl)
+ put(KEY_SECRET, config.secret)
+ put(KEY_DEVICE_ID, config.deviceId)
+ put(KEY_SHOW_NAME, config.showName)
+ put(KEY_ENABLED, config.enabled)
+ put(KEY_MEDIA_ENABLED, config.mediaEnabled)
+ put(KEY_MEDIA_DEVICE_ID, config.mediaDeviceId)
+ put(KEY_MEDIA_SHOW_NAME, config.mediaShowName)
+ put(KEY_MEDIA_METHOD, config.mediaMethod)
+ }
+ .toString()
+ }
- file.writeText(json.toString())
- true
+ private fun parseConfigJson(text: String): SleepyConfig? {
+ return try {
+ val json = JSONObject(text)
+ SleepyConfig(
+ serverUrl = json.optString(KEY_SERVER_URL, ""),
+ secret = json.optString(KEY_SECRET, ""),
+ deviceId = json.optString(KEY_DEVICE_ID, ""),
+ showName = json.optString(KEY_SHOW_NAME, ""),
+ enabled = json.optBoolean(KEY_ENABLED, false),
+ mediaEnabled = json.optBoolean(KEY_MEDIA_ENABLED, false),
+ mediaDeviceId = json.optString(KEY_MEDIA_DEVICE_ID, ""),
+ mediaShowName = json.optString(KEY_MEDIA_SHOW_NAME, ""),
+ mediaMethod = json.optString(KEY_MEDIA_METHOD, MediaMethod.AUTO.name)
+ )
} catch (_: Exception) {
- false
+ null
}
}
- private fun loadConfigFromFallbackFile(): SleepyConfig? {
- for (file in getHookFallbackConfigCandidates()) {
+ private fun saveConfigToJsonFiles(context: Context, config: SleepyConfig): Boolean {
+ val json = configToJson(config)
+ var any = false
+ for (file in getAllJsonCandidates(context)) {
try {
- if (!file.exists()) {
- continue
+ val parent = file.parentFile
+ if (parent != null && !parent.exists()) {
+ parent.mkdirs()
}
+ file.writeText(json)
+ // Best-effort world-readable so system_server can open without app identity.
+ file.setReadable(true, false)
+ parent?.setReadable(true, false)
+ parent?.setExecutable(true, false)
+ any = true
+ } catch (_: Exception) {}
+ }
+ return any
+ }
- val json = JSONObject(file.readText())
- val config =
- SleepyConfig(
- serverUrl = json.optString(KEY_SERVER_URL, ""),
- secret = json.optString(KEY_SECRET, ""),
- deviceId = json.optString(KEY_DEVICE_ID, ""),
- showName = json.optString(KEY_SHOW_NAME, ""),
- enabled = json.optBoolean(KEY_ENABLED, false)
- )
+ private fun loadConfigFromJsonFiles(context: Context?): SleepyConfig? {
+ for (file in getAllJsonCandidates(context)) {
+ try {
+ if (!file.exists() || !file.canRead()) continue
+ val config = parseConfigJson(file.readText()) ?: continue
if (config.hasRequiredFields()) {
return config
}
} catch (_: Exception) {}
}
-
return null
}
- private fun getAppFallbackConfigFile(context: Context): File {
- val appExternalDir = context.getExternalFilesDir(null)
- val baseDir =
- if (appExternalDir != null) {
- File(appExternalDir, FALLBACK_DIR)
- } else {
- File(
- Environment.getExternalStorageDirectory(),
- "Android/data/$MODULE_PACKAGE_NAME/files/$FALLBACK_DIR"
- )
- }
- return File(baseDir, FALLBACK_FILE_NAME)
+ private fun getPrimaryPublicConfigFile(): File {
+ return File(
+ "/storage/emulated/0/Android/media/$MODULE_PACKAGE_NAME/$FALLBACK_DIR/$FALLBACK_FILE_NAME"
+ )
}
- private fun getHookFallbackConfigCandidates(): List {
- val candidates = mutableListOf()
- val externalRoot = Environment.getExternalStorageDirectory()
+ private fun getAllJsonCandidates(context: Context?): List {
+ val files = linkedSetOf()
- candidates.add(
- File(
- externalRoot,
- "Android/data/$MODULE_PACKAGE_NAME/files/$FALLBACK_DIR/$FALLBACK_FILE_NAME"
- )
+ // Prefer Android/media — app can write without special storage permission; system can usually read.
+ files.add(getPrimaryPublicConfigFile())
+ files.add(
+ File("/sdcard/Android/media/$MODULE_PACKAGE_NAME/$FALLBACK_DIR/$FALLBACK_FILE_NAME")
)
- candidates.add(
+
+ if (context != null) {
+ try {
+ context.externalMediaDirs?.forEach { mediaDir ->
+ if (mediaDir != null) {
+ files.add(File(mediaDir, "$FALLBACK_DIR/$FALLBACK_FILE_NAME"))
+ }
+ }
+ } catch (_: Exception) {}
+ try {
+ context.getExternalFilesDir(null)?.let { ext ->
+ files.add(File(ext, "$FALLBACK_DIR/$FALLBACK_FILE_NAME"))
+ }
+ } catch (_: Exception) {}
+ }
+
+ val externalRoot = Environment.getExternalStorageDirectory()
+ files.add(File(externalRoot, "Android/media/$MODULE_PACKAGE_NAME/$FALLBACK_DIR/$FALLBACK_FILE_NAME"))
+ files.add(File(externalRoot, "Android/data/$MODULE_PACKAGE_NAME/files/$FALLBACK_DIR/$FALLBACK_FILE_NAME"))
+ files.add(File(externalRoot, "$FALLBACK_DIR/$FALLBACK_FILE_NAME"))
+ files.add(File("/sdcard/$FALLBACK_DIR/$FALLBACK_FILE_NAME"))
+ files.add(File("/storage/emulated/0/$FALLBACK_DIR/$FALLBACK_FILE_NAME"))
+ files.add(
File(
externalRoot,
- "Android/media/$MODULE_PACKAGE_NAME/$FALLBACK_DIR/$FALLBACK_FILE_NAME"
- )
- )
- candidates.add(
- File(
- "/sdcard/Android/data/$MODULE_PACKAGE_NAME/files/$FALLBACK_DIR/$FALLBACK_FILE_NAME"
- )
- )
- candidates.add(
- File(
- "/storage/emulated/0/Android/data/$MODULE_PACKAGE_NAME/files/$FALLBACK_DIR/$FALLBACK_FILE_NAME"
+ "Android/data/$MODULE_PACKAGE_NAME/files/$FALLBACK_DIR/$FALLBACK_FILE_NAME"
)
)
- return candidates
+ return files.toList()
}
- private fun getPrefContext(context: Context): Context {
- val deviceProtected = context.createDeviceProtectedStorageContext()
- // Move existing prefs so the system_server can read them before unlock
- deviceProtected.moveSharedPreferencesFrom(context, PREF_FILE_NAME)
- return deviceProtected
+ private fun getDeviceProtectedContext(context: Context): Context {
+ return context.createDeviceProtectedStorageContext()
}
private fun makePrefsWorldReadable(context: Context) {
@@ -217,7 +439,7 @@ object ConfigManager {
prefFile.setReadable(true, false)
}
} catch (_: Exception) {
- // Best-effort; fallback file will still be used if needed
+ // Best-effort
}
}
diff --git a/app/src/main/java/com/rhencloud/sleepyxposed/ForegroundAppMonitor.kt b/app/src/main/java/com/rhencloud/sleepyxposed/ForegroundAppMonitor.kt
index a1438e9..27245f6 100644
--- a/app/src/main/java/com/rhencloud/sleepyxposed/ForegroundAppMonitor.kt
+++ b/app/src/main/java/com/rhencloud/sleepyxposed/ForegroundAppMonitor.kt
@@ -111,26 +111,44 @@ class ForegroundAppMonitor(private val log: (String) -> Unit) {
val completeResume: Method = activityRecordClass.getDeclaredMethod("completeResumeLocked")
completeResume.isAccessible = true
- ModuleMain.instance?.hook(completeResume)?.intercept { chain ->
+ val module = ModuleMain.instance
+ if (module == null) {
+ log("$TAG: ModuleMain.instance is null, cannot install hook")
+ return
+ }
+
+ module.hook(completeResume).intercept { chain ->
val result = chain.proceed()
try {
- val activityRecord = chain.thisObject
- val packageName = getField(activityRecord, "packageName") as? String
- val activityInfo = getField(activityRecord, "info")
- val activityName = activityInfo?.let { getField(it, "name") as? String }
-
- if (packageName != null) {
- currentForegroundPackage = packageName
- currentForegroundActivity = activityName
-
- if (packageName != lastForegroundPackage) {
- lastForegroundPackage = packageName
- val appName = systemContext?.let { getAppDisplayName(it, packageName) } ?: packageName
- val componentName =
- if (activityName != null) "$packageName/$activityName/$appName" else packageName
- log("$TAG: Foreground app switched to: $componentName")
- executeCustomOperations(packageName)
- }
+ val activityRecord = chain.thisObject ?: return@intercept result
+
+ // packageName is enough to report; activity name is best-effort only.
+ // Legacy XposedHelpers.getObjectField walked superclasses; plain
+ // getDeclaredField does not (ActivityInfo.name lives on ComponentInfo).
+ val packageName = getFieldOrNull(activityRecord, "packageName") as? String
+ if (packageName.isNullOrBlank()) {
+ return@intercept result
+ }
+
+ val activityName =
+ runCatching {
+ val activityInfo = getFieldOrNull(activityRecord, "info")
+ activityInfo?.let { getFieldOrNull(it, "name") as? String }
+ }
+ .getOrNull()
+
+ currentForegroundPackage = packageName
+ currentForegroundActivity = activityName
+
+ if (packageName != lastForegroundPackage) {
+ lastForegroundPackage = packageName
+ val appName =
+ systemContext?.let { getAppDisplayName(it, packageName) } ?: packageName
+ val componentName =
+ if (activityName != null) "$packageName/$activityName/$appName"
+ else packageName
+ log("$TAG: Foreground app switched to: $componentName")
+ executeCustomOperations(packageName)
}
} catch (e: Throwable) {
log("$TAG: Error in hook: ${e.message}")
@@ -141,15 +159,31 @@ class ForegroundAppMonitor(private val log: (String) -> Unit) {
log("$TAG: Successfully hooked into ActivityRecord.completeResumeLocked")
}
- private fun getField(target: Any, name: String): Any? {
- val field = target.javaClass.getDeclaredField(name)
- field.isAccessible = true
- return field.get(target)
+ /**
+ * Read an instance field by name, walking the superclass chain.
+ *
+ * Equivalent of legacy [de.robv.android.xposed.XposedHelpers.getObjectField]:
+ * [Class.getDeclaredField] only searches the exact class, so inherited fields
+ * such as [android.content.pm.ComponentInfo.name] on [android.content.pm.ActivityInfo]
+ * would otherwise throw NoSuchFieldException.
+ */
+ private fun getFieldOrNull(target: Any, name: String): Any? {
+ var clazz: Class<*>? = target.javaClass
+ while (clazz != null) {
+ try {
+ val field = clazz.getDeclaredField(name)
+ field.isAccessible = true
+ return field.get(target)
+ } catch (_: NoSuchFieldException) {
+ clazz = clazz.superclass
+ }
+ }
+ return null
}
private fun loadConfiguration() {
try {
- val sleepyConfig = ConfigManager.loadConfigFromXSharedPreferences()
+ val sleepyConfig = ConfigManager.loadConfigFromXSharedPreferences(systemContext)
cachedConfig =
Config(
url = sleepyConfig.serverUrl,
@@ -158,6 +192,15 @@ class ForegroundAppMonitor(private val log: (String) -> Unit) {
showName = sleepyConfig.showName,
enabled = sleepyConfig.enabled
)
+ if (isConfigUsable(cachedConfig)) {
+ log(
+ "$TAG: Config loaded (enabled=${sleepyConfig.enabled}, url=${sleepyConfig.serverUrl})"
+ )
+ } else {
+ log(
+ "$TAG: Config incomplete or empty after load — ${ConfigManager.describeLoadSources(systemContext)}"
+ )
+ }
} catch (e: Exception) {
log("$TAG: Failed to load configuration: ${e.message}")
}
diff --git a/app/src/main/java/com/rhencloud/sleepyxposed/MainActivity.kt b/app/src/main/java/com/rhencloud/sleepyxposed/MainActivity.kt
index 932ba83..e1e4eef 100644
--- a/app/src/main/java/com/rhencloud/sleepyxposed/MainActivity.kt
+++ b/app/src/main/java/com/rhencloud/sleepyxposed/MainActivity.kt
@@ -1,6 +1,8 @@
package com.rhencloud.sleepyxposed
+import android.content.Intent
import android.os.Bundle
+import android.provider.Settings
import android.view.View
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
@@ -14,27 +16,43 @@ class MainActivity : AppCompatActivity() {
private lateinit var saveButton: Button
private lateinit var statusText: TextView
+ // Media status reporting views
+ private lateinit var mediaEnabledSwitch: Switch
+ private lateinit var mediaDeviceIdEdit: EditText
+ private lateinit var mediaShowNameEdit: EditText
+ private lateinit var mediaMethodGroup: RadioGroup
+ private lateinit var mediaRecommendationText: TextView
+ private lateinit var mediaNotificationPermissionButton: Button
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
- // Initialize views
+ serverUrlEdit = findViewById(R.id.server_url)
+ secretEdit = findViewById(R.id.secret)
+ deviceIdEdit = findViewById(R.id.device_id)
+ showNameEdit = findViewById(R.id.show_name)
+ enabledSwitch = findViewById(R.id.enabled_switch)
+ saveButton = findViewById(R.id.save_button)
+ statusText = findViewById(R.id.status_text)
+
+ mediaEnabledSwitch = findViewById(R.id.media_enabled_switch)
+ mediaDeviceIdEdit = findViewById(R.id.media_device_id)
+ mediaShowNameEdit = findViewById(R.id.media_show_name)
+ mediaMethodGroup = findViewById(R.id.media_method_group)
+ mediaRecommendationText = findViewById(R.id.media_recommendation_text)
+ mediaNotificationPermissionButton = findViewById(R.id.media_notification_permission_button)
+
+ // Always wire buttons first so a load/recommendation failure cannot leave UI dead.
+ saveButton.setOnClickListener { saveConfiguration() }
+ mediaNotificationPermissionButton.setOnClickListener { openNotificationListenerSettings() }
+
try {
- serverUrlEdit = findViewById(R.id.server_url)
- secretEdit = findViewById(R.id.secret)
- deviceIdEdit = findViewById(R.id.device_id)
- showNameEdit = findViewById(R.id.show_name)
- enabledSwitch = findViewById(R.id.enabled_switch)
- saveButton = findViewById(R.id.save_button)
- statusText = findViewById(R.id.status_text)
-
- // Load saved configuration from JSON file
loadConfiguration()
-
- // Set up save button
- saveButton.setOnClickListener { saveConfiguration() }
+ updateRecommendationText()
} catch (e: Exception) {
- Toast.makeText(this, "Error initializing UI: ${e.message}", Toast.LENGTH_LONG).show()
+ Toast.makeText(this, "Error loading configuration: ${e.message}", Toast.LENGTH_LONG)
+ .show()
e.printStackTrace()
}
}
@@ -47,6 +65,19 @@ class MainActivity : AppCompatActivity() {
deviceIdEdit.setText(config.deviceId)
showNameEdit.setText(config.showName)
enabledSwitch.isChecked = config.enabled
+
+ mediaEnabledSwitch.isChecked = config.mediaEnabled
+ mediaDeviceIdEdit.setText(config.mediaDeviceId)
+ mediaShowNameEdit.setText(config.mediaShowName)
+
+ val radioId =
+ when (MediaMethod.fromString(config.mediaMethod)) {
+ MediaMethod.SYSTEM_HOOK -> R.id.media_method_system_hook
+ MediaMethod.NOTIFICATION_LISTENER -> R.id.media_method_notification_listener
+ MediaMethod.DUMPSYS_SHELL -> R.id.media_method_dumpsys_shell
+ MediaMethod.AUTO -> R.id.media_method_auto
+ }
+ mediaMethodGroup.check(radioId)
}
private fun saveConfiguration() {
@@ -61,6 +92,16 @@ class MainActivity : AppCompatActivity() {
return
}
+ val mediaEnabled = mediaEnabledSwitch.isChecked
+ val mediaDeviceId = mediaDeviceIdEdit.text.toString()
+ val mediaShowName = mediaShowNameEdit.text.toString()
+
+ if (mediaEnabled && (mediaDeviceId.isEmpty() || mediaShowName.isEmpty())) {
+ Toast.makeText(this, getString(R.string.media_fill_required_fields), Toast.LENGTH_SHORT)
+ .show()
+ return
+ }
+
// Create config object
val config =
SleepyConfig(
@@ -68,14 +109,21 @@ class MainActivity : AppCompatActivity() {
secret = secret,
deviceId = id,
showName = showName,
- enabled = enabled
+ enabled = enabled,
+ mediaEnabled = mediaEnabled,
+ mediaDeviceId = mediaDeviceId,
+ mediaShowName = mediaShowName,
+ mediaMethod = selectedMediaMethod().name
)
- // Save to JSON file
val success = ConfigManager.saveConfig(this, config)
if (success) {
- statusText.text = getString(R.string.config_saved)
+ // Include mirror path so user can verify system_server-readable config exists.
+ statusText.text =
+ getString(R.string.config_saved) +
+ "\n\n" +
+ ConfigManager.getConfigFilePath(this)
statusText.visibility = View.VISIBLE
Toast.makeText(this, getString(R.string.config_saved_toast), Toast.LENGTH_SHORT).show()
@@ -83,4 +131,42 @@ class MainActivity : AppCompatActivity() {
Toast.makeText(this, "Failed to save configuration", Toast.LENGTH_SHORT).show()
}
}
+
+ private fun selectedMediaMethod(): MediaMethod {
+ return when (mediaMethodGroup.checkedRadioButtonId) {
+ R.id.media_method_system_hook -> MediaMethod.SYSTEM_HOOK
+ R.id.media_method_notification_listener -> MediaMethod.NOTIFICATION_LISTENER
+ R.id.media_method_dumpsys_shell -> MediaMethod.DUMPSYS_SHELL
+ else -> MediaMethod.AUTO
+ }
+ }
+
+ /** Shows the detected Android version / System UI and the auto-recommended method. */
+ private fun updateRecommendationText() {
+ val recommendation = RomDetector.recommend(this)
+ val methodLabel =
+ when (recommendation.method) {
+ MediaMethod.SYSTEM_HOOK -> getString(R.string.media_method_system_hook)
+ MediaMethod.NOTIFICATION_LISTENER ->
+ getString(R.string.media_method_notification_listener)
+ else -> getString(R.string.media_method_system_hook)
+ }
+
+ mediaRecommendationText.text =
+ getString(
+ R.string.media_recommendation_format,
+ recommendation.androidVersion,
+ recommendation.rom.displayName,
+ methodLabel,
+ recommendation.reason
+ )
+ }
+
+ private fun openNotificationListenerSettings() {
+ try {
+ startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
+ } catch (e: Exception) {
+ Toast.makeText(this, "Failed to open settings: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
}
diff --git a/app/src/main/java/com/rhencloud/sleepyxposed/MediaListenerService.kt b/app/src/main/java/com/rhencloud/sleepyxposed/MediaListenerService.kt
new file mode 100644
index 0000000..30869d0
--- /dev/null
+++ b/app/src/main/java/com/rhencloud/sleepyxposed/MediaListenerService.kt
@@ -0,0 +1,133 @@
+package com.rhencloud.sleepyxposed
+
+import android.content.ComponentName
+import android.content.Context
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.MediaSessionManager
+import android.media.session.PlaybackState
+import android.service.notification.NotificationListenerService
+import android.service.notification.StatusBarNotification
+import android.util.Log
+import java.io.IOException
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Response
+
+/**
+ * App-side fallback for capturing media playback status via the standard
+ * `NotificationListenerService` API. Requires the user to grant "Notification access" once from
+ * system settings (see [MainActivity]'s "grant notification access" button), but is the most
+ * broadly compatible option across heavily customized ROMs where the system-hook based
+ * [MediaStatusMonitor] may be unreliable.
+ *
+ * This service only actively reports when the configured (or auto-resolved) [MediaMethod] is
+ * [MediaMethod.NOTIFICATION_LISTENER], so it stays silent when the user picked a different
+ * method, avoiding duplicate reports with [MediaStatusMonitor].
+ */
+class MediaListenerService : NotificationListenerService() {
+
+ companion object {
+ private const val TAG = "SleepyXposed-MediaListener"
+ private const val NOT_PLAYING_STATUS = "未在播放"
+ }
+
+ private var mediaSessionManager: MediaSessionManager? = null
+ private var sessionsChangedListener: MediaSessionManager.OnActiveSessionsChangedListener? = null
+ private var lastStatus: String? = null
+
+ override fun onListenerConnected() {
+ super.onListenerConnected()
+ try {
+ mediaSessionManager =
+ getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager
+ val componentName = ComponentName(this, MediaListenerService::class.java)
+
+ val listener =
+ MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
+ handleControllers(controllers)
+ }
+ sessionsChangedListener = listener
+ mediaSessionManager?.addOnActiveSessionsChangedListener(listener, componentName)
+ handleControllers(mediaSessionManager?.getActiveSessions(componentName))
+ Log.i(TAG, "Notification listener connected, media session watcher started")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start media session watcher: ${e.message}")
+ }
+ }
+
+ override fun onListenerDisconnected() {
+ super.onListenerDisconnected()
+ sessionsChangedListener?.let { mediaSessionManager?.removeOnActiveSessionsChangedListener(it) }
+ sessionsChangedListener = null
+ }
+
+ override fun onNotificationPosted(sbn: StatusBarNotification) {
+ // Media state changes are already observed via the active-sessions listener.
+ }
+
+ override fun onNotificationRemoved(sbn: StatusBarNotification) {
+ // No-op
+ }
+
+ private fun handleControllers(controllers: List?) {
+ val config = ConfigManager.loadConfig(this)
+ if (!config.enabled || !config.mediaEnabled) return
+ if (config.mediaDeviceId.isBlank() || config.mediaShowName.isBlank()) return
+ if (config.serverUrl.isBlank() || config.secret.isBlank()) return
+
+ val configuredMethod = MediaMethod.fromString(config.mediaMethod)
+ val resolvedMethod = RomDetector.resolveMethod(applicationContext, configuredMethod)
+ // Only this service should report when NOTIFICATION_LISTENER is the active method;
+ // otherwise MediaStatusMonitor (system_server side) is responsible.
+ if (resolvedMethod != MediaMethod.NOTIFICATION_LISTENER) return
+
+ val playing =
+ controllers?.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PLAYING }
+
+ val status: String
+ val using: Boolean
+ val title =
+ playing?.metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)?.takeIf { it.isNotBlank() }
+ if (playing != null && title != null) {
+ val artist =
+ playing.metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST)
+ ?: playing.metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST) ?: ""
+ status = if (artist.isNotBlank()) "♪$title - $artist" else "♪$title"
+ using = true
+ } else {
+ status = NOT_PLAYING_STATUS
+ using = false
+ }
+
+ if (status == lastStatus) return
+ lastStatus = status
+
+ try {
+ SleepyApiClient.sendDeviceStatus(
+ baseUrl = config.serverUrl,
+ secret = config.secret,
+ id = config.mediaDeviceId,
+ showName = config.mediaShowName,
+ using = using,
+ status = status,
+ callback =
+ object : Callback {
+ override fun onFailure(call: Call, e: IOException) {
+ Log.w(TAG, "Failed to send media status: ${e.message}")
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ response.use {
+ if (!response.isSuccessful) {
+ Log.w(TAG, "Server error: ${response.code}")
+ }
+ }
+ }
+ }
+ )
+ } catch (e: Exception) {
+ Log.w(TAG, "Error sending media status: ${e.message}")
+ }
+ }
+}
diff --git a/app/src/main/java/com/rhencloud/sleepyxposed/MediaMethod.kt b/app/src/main/java/com/rhencloud/sleepyxposed/MediaMethod.kt
new file mode 100644
index 0000000..07dab73
--- /dev/null
+++ b/app/src/main/java/com/rhencloud/sleepyxposed/MediaMethod.kt
@@ -0,0 +1,27 @@
+package com.rhencloud.sleepyxposed
+
+/**
+ * Strategies for acquiring the current media playback status.
+ *
+ * - [AUTO]: let the module decide at runtime, based on [RomDetector]'s recommendation for the
+ * current Android version / System UI vendor. Resolves to either [SYSTEM_HOOK] or
+ * [NOTIFICATION_LISTENER].
+ * - [SYSTEM_HOOK]: query `MediaSessionManager` directly from the hooked system_server process.
+ * Zero extra setup, but depends on the Xposed hook staying active and the ROM not restricting
+ * system-side session queries.
+ * - [NOTIFICATION_LISTENER]: run a `NotificationListenerService` inside the app process itself.
+ * Requires the user to grant "Notification access" once, but is the most broadly compatible
+ * option across heavily customized ROMs.
+ * - [DUMPSYS_SHELL]: shell out to `dumpsys media_session` and parse its text output, mirroring
+ * the approach used by Magisk-based Sleepy scripts. Kept as a manual fallback/debug option.
+ */
+enum class MediaMethod {
+ AUTO,
+ SYSTEM_HOOK,
+ NOTIFICATION_LISTENER,
+ DUMPSYS_SHELL;
+
+ companion object {
+ fun fromString(value: String?): MediaMethod = values().find { it.name == value } ?: AUTO
+ }
+}
diff --git a/app/src/main/java/com/rhencloud/sleepyxposed/MediaStatusMonitor.kt b/app/src/main/java/com/rhencloud/sleepyxposed/MediaStatusMonitor.kt
new file mode 100644
index 0000000..d3c301d
--- /dev/null
+++ b/app/src/main/java/com/rhencloud/sleepyxposed/MediaStatusMonitor.kt
@@ -0,0 +1,216 @@
+package com.rhencloud.sleepyxposed
+
+import android.content.Context
+import android.media.MediaMetadata
+import android.media.session.MediaSessionManager
+import android.media.session.PlaybackState
+import android.os.Handler
+import android.os.Looper
+import java.io.BufferedReader
+import java.io.IOException
+import java.io.InputStreamReader
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Response
+
+/**
+ * Monitors media playback status from within the system_server process and reports it to the
+ * Sleepy server. Handles the two "system side" acquisition strategies:
+ *
+ * - [MediaMethod.SYSTEM_HOOK]: uses [MediaSessionManager.getActiveSessions] directly. This works
+ * without a registered notification-listener component because the call executes with the
+ * system process's own identity, which implicitly holds `MEDIA_CONTENT_CONTROL`.
+ * - [MediaMethod.DUMPSYS_SHELL]: shells out to `dumpsys media_session` and parses its text
+ * output, mirroring the approach used by Magisk-based Sleepy scripts. Kept as a fallback for
+ * ROMs whose `MediaSessionManager` implementation has been modified or restricted.
+ *
+ * [MediaMethod.NOTIFICATION_LISTENER] is intentionally NOT handled here; it is served by
+ * [MediaListenerService] running inside the app's own process. Both sides resolve
+ * [MediaMethod.AUTO] independently via [RomDetector.resolveMethod], so exactly one of them ends
+ * up actively reporting for a given device.
+ */
+class MediaStatusMonitor(private val log: (String) -> Unit) {
+
+ companion object {
+ private const val TAG = "SleepyXposed-Media"
+ private const val POLL_INTERVAL_MS = 8_000L
+ private val DUMPSYS_DESCRIPTION_REGEX = Regex("description=([^,]+),\\s*([^,]+)")
+ private const val NOT_PLAYING_STATUS = "未在播放"
+ }
+
+ private data class MediaInfo(val title: String, val artist: String)
+
+ private var handler: Handler? = null
+ private var systemContext: Context? = null
+ private var pollRunnable: Runnable? = null
+ private var lastStatus: String? = null
+ private var lastSkipReason: String? = null
+
+ fun initializeForSystemServer(classLoader: ClassLoader) {
+ log("$TAG: Initializing media status monitor...")
+ try {
+ val activityThreadClass =
+ Class.forName("android.app.ActivityThread", false, classLoader)
+ val currentActivityThread =
+ activityThreadClass.getMethod("currentActivityThread").invoke(null)
+ val getSystemContext = activityThreadClass.getMethod("getSystemContext")
+ systemContext = getSystemContext.invoke(currentActivityThread) as? Context
+
+ val context = systemContext
+ if (context == null) {
+ log("$TAG: Failed to obtain system context, media monitor disabled")
+ return
+ }
+
+ val looper = context.mainLooper ?: Looper.myLooper() ?: Looper.getMainLooper()
+ handler = Handler(looper)
+ schedulePoll(POLL_INTERVAL_MS)
+ log("$TAG: Media status monitor initialized")
+ } catch (e: Exception) {
+ log("$TAG: Failed to initialize media monitor: ${e.message}")
+ }
+ }
+
+ private fun schedulePoll(delay: Long) {
+ val currentHandler = handler ?: return
+ pollRunnable?.let { currentHandler.removeCallbacks(it) }
+ val runnable = Runnable {
+ try {
+ poll()
+ } catch (e: Exception) {
+ log("$TAG: Poll error: ${e.message}")
+ }
+ schedulePoll(POLL_INTERVAL_MS)
+ }
+ pollRunnable = runnable
+ currentHandler.postDelayed(runnable, delay)
+ }
+
+ private fun poll() {
+ val config = ConfigManager.loadConfigFromXSharedPreferences(systemContext)
+ if (!config.enabled) {
+ return
+ }
+ if (!config.mediaEnabled) {
+ return
+ }
+ if (config.mediaDeviceId.isBlank() || config.mediaShowName.isBlank()) {
+ logSkipOnce("media device id / show name empty")
+ return
+ }
+ if (config.serverUrl.isBlank() || config.secret.isBlank()) {
+ logSkipOnce(
+ "server url / secret empty — ${ConfigManager.describeLoadSources(systemContext)}"
+ )
+ return
+ }
+ lastSkipReason = null
+
+ val configuredMethod = MediaMethod.fromString(config.mediaMethod)
+ val resolvedMethod = RomDetector.resolveMethod(systemContext, configuredMethod)
+
+ // NOTIFICATION_LISTENER is handled by the app-process service, not here.
+ if (resolvedMethod != MediaMethod.SYSTEM_HOOK && resolvedMethod != MediaMethod.DUMPSYS_SHELL) {
+ return
+ }
+
+ val info =
+ if (resolvedMethod == MediaMethod.DUMPSYS_SHELL) readViaDumpsys() else readViaMediaSessionManager()
+
+ val status: String
+ val using: Boolean
+ if (info != null) {
+ status = if (info.artist.isNotBlank()) "♪${info.title} - ${info.artist}" else "♪${info.title}"
+ using = true
+ } else {
+ status = NOT_PLAYING_STATUS
+ using = false
+ }
+
+ if (status == lastStatus) return
+ lastStatus = status
+ log("$TAG: Media status changed via $resolvedMethod: $status")
+
+ try {
+ SleepyApiClient.sendDeviceStatus(
+ baseUrl = config.serverUrl,
+ secret = config.secret,
+ id = config.mediaDeviceId,
+ showName = config.mediaShowName,
+ using = using,
+ status = status,
+ callback =
+ object : Callback {
+ override fun onFailure(call: Call, e: IOException) {
+ log("$TAG: Failed to send media status: ${e.message}")
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ response.use {
+ if (!response.isSuccessful) {
+ log("$TAG: Server error: ${response.code}")
+ }
+ }
+ }
+ }
+ )
+ } catch (e: Exception) {
+ log("$TAG: Error sending media status: ${e.message}")
+ }
+ }
+
+ private fun logSkipOnce(reason: String) {
+ if (lastSkipReason == reason) return
+ lastSkipReason = reason
+ log("$TAG: Media report skipped: $reason")
+ }
+
+ private fun readViaMediaSessionManager(): MediaInfo? {
+ val context = systemContext ?: return null
+ return try {
+ val manager =
+ context.getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager
+ ?: return null
+ val sessions = manager.getActiveSessions(null)
+ val playing =
+ sessions.firstOrNull { controller ->
+ controller.playbackState?.state == PlaybackState.STATE_PLAYING
+ } ?: return null
+
+ val metadata = playing.metadata ?: return null
+ val title =
+ metadata.getString(MediaMetadata.METADATA_KEY_TITLE)?.takeIf { it.isNotBlank() }
+ ?: return null
+ val artist =
+ metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)
+ ?: metadata.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST) ?: ""
+ MediaInfo(title, artist)
+ } catch (e: SecurityException) {
+ log("$TAG: MediaSessionManager access denied: ${e.message}")
+ null
+ } catch (e: Exception) {
+ log("$TAG: MediaSessionManager read failed: ${e.message}")
+ null
+ }
+ }
+
+ private fun readViaDumpsys(): MediaInfo? {
+ return try {
+ val process =
+ ProcessBuilder("dumpsys", "media_session").redirectErrorStream(true).start()
+ val output =
+ BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() }
+ process.waitFor()
+
+ if (!output.contains("state=PLAYING")) return null
+
+ val match = DUMPSYS_DESCRIPTION_REGEX.find(output) ?: return null
+ val title = match.groupValues[1].trim()
+ val artist = match.groupValues[2].trim().let { if (it == "null") "" else it }
+ if (title.isBlank()) null else MediaInfo(title, artist)
+ } catch (e: Exception) {
+ log("$TAG: dumpsys read failed: ${e.message}")
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/com/rhencloud/sleepyxposed/ModuleMain.kt b/app/src/main/java/com/rhencloud/sleepyxposed/ModuleMain.kt
index a9fbe60..ce855dc 100644
--- a/app/src/main/java/com/rhencloud/sleepyxposed/ModuleMain.kt
+++ b/app/src/main/java/com/rhencloud/sleepyxposed/ModuleMain.kt
@@ -26,5 +26,13 @@ class ModuleMain : XposedModule() {
} catch (t: Throwable) {
log(Log.ERROR, TAG, "Modern API bootstrap failed", t)
}
+
+ try {
+ MediaStatusMonitor { message -> log(Log.INFO, TAG, message) }
+ .initializeForSystemServer(param.classLoader)
+ log(Log.INFO, TAG, "Media status monitor bootstrap succeeded")
+ } catch (t: Throwable) {
+ log(Log.ERROR, TAG, "Media status monitor bootstrap failed", t)
+ }
}
}
diff --git a/app/src/main/java/com/rhencloud/sleepyxposed/RomDetector.kt b/app/src/main/java/com/rhencloud/sleepyxposed/RomDetector.kt
new file mode 100644
index 0000000..999cc53
--- /dev/null
+++ b/app/src/main/java/com/rhencloud/sleepyxposed/RomDetector.kt
@@ -0,0 +1,123 @@
+package com.rhencloud.sleepyxposed
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+
+/**
+ * Detects the current ROM / System UI vendor and recommends a [MediaMethod] for capturing media
+ * playback status.
+ *
+ * Heavily customized ROMs (MIUI/HyperOS, ColorOS, FuntouchOS/OriginOS, EMUI/MagicUI, One UI,
+ * Flyme, ...) are known to apply aggressive background/process restrictions that can make a
+ * pure system-hook approach (querying `MediaSessionManager` from within system_server)
+ * unreliable. For those, the standard `NotificationListenerService` API is recommended instead,
+ * since it is the officially supported cross-ROM mechanism (at the cost of a one-time manual
+ * permission grant). Near-stock/AOSP environments get the zero-setup system hook.
+ */
+object RomDetector {
+
+ enum class RomFamily(val displayName: String) {
+ MIUI("MIUI / HyperOS"),
+ COLOR_OS("ColorOS / RealmeUI"),
+ FUNTOUCH_OS("FuntouchOS / OriginOS"),
+ EMUI("EMUI / MagicUI"),
+ ONE_UI("One UI"),
+ FLYME("Flyme"),
+ STOCK("原生 / 接近 AOSP")
+ }
+
+ data class Recommendation(
+ val method: MediaMethod,
+ val rom: RomFamily,
+ val androidVersion: String,
+ val reason: String
+ )
+
+ /** Recommend a concrete method (never [MediaMethod.AUTO] or [MediaMethod.DUMPSYS_SHELL]). */
+ fun recommend(context: Context?): Recommendation {
+ val rom = detectRom(context)
+ val versionName = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
+
+ return if (rom == RomFamily.STOCK) {
+ Recommendation(
+ method = MediaMethod.SYSTEM_HOOK,
+ rom = rom,
+ androidVersion = versionName,
+ reason = "检测到接近原生的系统环境,系统钩子方式无需额外授权,兼容性最好"
+ )
+ } else {
+ Recommendation(
+ method = MediaMethod.NOTIFICATION_LISTENER,
+ rom = rom,
+ androidVersion = versionName,
+ reason = "检测到定制系统「${rom.displayName}」,其后台管控可能影响系统钩子的稳定性," +
+ "推荐使用通知监听方式(需在本应用内手动授权一次通知访问权限)"
+ )
+ }
+ }
+
+ /** Resolve [MediaMethod.AUTO] to a concrete method; other values pass through unchanged. */
+ fun resolveMethod(context: Context?, configured: MediaMethod): MediaMethod {
+ return if (configured == MediaMethod.AUTO) recommend(context).method else configured
+ }
+
+ private fun detectRom(context: Context?): RomFamily {
+ if (getSystemProperty("ro.miui.ui.version.name")?.isNotBlank() == true) return RomFamily.MIUI
+ if (getSystemProperty("ro.mi.os.version.name")?.isNotBlank() == true) return RomFamily.MIUI
+ if (getSystemProperty("ro.build.version.opporom")?.isNotBlank() == true) return RomFamily.COLOR_OS
+ if (getSystemProperty("ro.build.version.oplusrom")?.isNotBlank() == true) return RomFamily.COLOR_OS
+ if (getSystemProperty("ro.vivo.os.build.display.id")?.isNotBlank() == true) return RomFamily.FUNTOUCH_OS
+ if (getSystemProperty("ro.vivo.os.version")?.isNotBlank() == true) return RomFamily.FUNTOUCH_OS
+ if (getSystemProperty("ro.build.version.emui")?.isNotBlank() == true) return RomFamily.EMUI
+ if (getSystemProperty("ro.build.version.magic")?.isNotBlank() == true) return RomFamily.EMUI
+ if (getSystemProperty("ro.flyme.published")?.isNotBlank() == true) return RomFamily.FLYME
+ if (getSystemProperty("ro.build.display.id")?.contains("flyme", ignoreCase = true) == true) {
+ return RomFamily.FLYME
+ }
+
+ val manufacturer = Build.MANUFACTURER.lowercase()
+ val brand = Build.BRAND.lowercase()
+ when {
+ manufacturer.contains("xiaomi") || brand.contains("xiaomi") ||
+ brand.contains("redmi") || brand.contains("poco") -> return RomFamily.MIUI
+ manufacturer.contains("oppo") || brand.contains("oppo") ||
+ brand.contains("realme") || brand.contains("oneplus") -> return RomFamily.COLOR_OS
+ manufacturer.contains("vivo") || brand.contains("vivo") || brand.contains("iqoo") ->
+ return RomFamily.FUNTOUCH_OS
+ manufacturer.contains("huawei") || brand.contains("huawei") || brand.contains("honor") ->
+ return RomFamily.EMUI
+ manufacturer.contains("samsung") -> return RomFamily.ONE_UI
+ manufacturer.contains("meizu") -> return RomFamily.FLYME
+ }
+
+ if (context != null) {
+ if (hasPackage(context, "com.miui.securitycenter")) return RomFamily.MIUI
+ if (hasPackage(context, "com.coloros.safecenter")) return RomFamily.COLOR_OS
+ if (hasPackage(context, "com.vivo.abe")) return RomFamily.FUNTOUCH_OS
+ if (hasPackage(context, "com.huawei.systemmanager")) return RomFamily.EMUI
+ }
+
+ return RomFamily.STOCK
+ }
+
+ private fun hasPackage(context: Context, packageName: String): Boolean {
+ return try {
+ context.packageManager.getPackageInfo(packageName, PackageManager.GET_META_DATA)
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ /** Reads a system property via reflection; works from both app and system_server contexts. */
+ private fun getSystemProperty(key: String): String? {
+ return try {
+ val clazz = Class.forName("android.os.SystemProperties")
+ val method = clazz.getMethod("get", String::class.java)
+ method.invoke(null, key) as? String
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 71ac5cd..29284b3 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -115,6 +115,133 @@
android:layout_height="wrap_content" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+