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" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +