From 8c7fdf8fa90505f991a7b3f14b6448c19a187b4d Mon Sep 17 00:00:00 2001 From: XFJ-YYQF Date: Fri, 31 Jul 2026 10:20:24 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=96=B0=E4=B8=89=E5=B9=B4=EF=BC=8C?= =?UTF-8?q?=E6=97=A7=E4=B8=89=E5=B9=B4=EF=BC=8C=E7=BC=9D=E7=BC=9D=E8=A1=A5?= =?UTF-8?q?=E8=A1=A5=E5=8F=88=E4=B8=89=E5=B9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +++ app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 11 + .../rhencloud/sleepyxposed/ConfigManager.kt | 49 ++++- .../rhencloud/sleepyxposed/MainActivity.kt | 87 +++++++- .../sleepyxposed/MediaListenerService.kt | 133 ++++++++++++ .../com/rhencloud/sleepyxposed/MediaMethod.kt | 27 +++ .../sleepyxposed/MediaStatusMonitor.kt | 194 ++++++++++++++++++ .../com/rhencloud/sleepyxposed/ModuleMain.kt | 8 + .../com/rhencloud/sleepyxposed/RomDetector.kt | 123 +++++++++++ app/src/main/res/layout/activity_main.xml | 127 ++++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 18 ++ app/src/main/res/values/strings.xml | 22 ++ 13 files changed, 818 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/rhencloud/sleepyxposed/MediaListenerService.kt create mode 100644 app/src/main/java/com/rhencloud/sleepyxposed/MediaMethod.kt create mode 100644 app/src/main/java/com/rhencloud/sleepyxposed/MediaStatusMonitor.kt create mode 100644 app/src/main/java/com/rhencloud/sleepyxposed/RomDetector.kt 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..d557446 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,17 @@ + + + + + + + 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 +94,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,7 +111,11 @@ 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 @@ -83,4 +130,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..acca78b --- /dev/null +++ b/app/src/main/java/com/rhencloud/sleepyxposed/MediaStatusMonitor.kt @@ -0,0 +1,194 @@ +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 + + 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() + 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(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 + + 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 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" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +