diff --git a/README.md b/README.md
index be3cde7..7b10839 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@
| 方式 | 说明 | 是否需要额外授权 |
| ------------------ | -------------------------------------------------------------------- | ----------------- |
-| **自动(推荐)** | 根据检测到的 Android 版本和系统 UI(ROM)厂商,自动在下面两种方式中选择最合适的一种 | 视结果而定 |
+| **自动(推荐)** | 根据检测到的 Android 版本和系统 UI(ROM)厂商,自动在「系统钩子」和「通知监听」两种方式中选择最合适的一种(不自动选择 Dumpsys Shell) | 视结果而定 |
| **系统钩子** | 通过 Xposed 钩子在 system_server 进程内直接调用 `MediaSessionManager` 获取,零配置 | 否 |
| **通知监听** | 使用标准 `NotificationListenerService` API,在应用自身进程内运行,跨 ROM 兼容性最好 | 是(需在设置中手动授权一次,可点击应用内按钮直达) |
| **Dumpsys Shell** | 通过解析 `dumpsys media_session` 命令输出获取,思路参考自 Magisk 版 Sleepy 脚本,作为兼容性/调试备用方案 | 否(复用系统钩子权限) |
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ee2cc91..d40c4d8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,79 +1,127 @@
-import java.util.Properties
-
-plugins {
- id("com.android.application")
- kotlin("android")
-}
-
-val keystorePropertiesFile = rootProject.file("keystore.properties")
-val keystoreProperties = Properties().apply {
- if (keystorePropertiesFile.exists()) {
- keystorePropertiesFile.inputStream().use { load(it) }
- }
-}
-
-android {
- namespace = "io.github.recloudstudio.sleepyxposed"
- compileSdk = 34
-
+import java.util.Properties
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("com.android.application")
+ kotlin("android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+val keystorePropertiesFile = rootProject.file("keystore.properties")
+val keystoreProperties = Properties().apply {
+ if (keystorePropertiesFile.exists()) {
+ keystorePropertiesFile.inputStream().use { load(it) }
+ }
+}
+
+android {
+ namespace = "io.github.recloudstudio.sleepyxposed"
+ compileSdk = 36
+
defaultConfig {
applicationId = "io.github.recloudstudio.sleepyxposed"
minSdk = 24
- targetSdk = 34
- versionCode = 2
+ targetSdk = 35
+ versionCode = 2
versionName = "2.0"
+ buildConfigField("int", "XPOSED_API", "101")
+ buildConfigField("String", "MODULE_CHANNEL", "\"release\"")
+
+ // Phones only — drop x86 emulator ABIs from the APK
+ ndk {
+ abiFilters += listOf("armeabi-v7a", "arm64-v8a")
+ }
+ }
+
+ signingConfigs {
+ if (keystorePropertiesFile.exists()) {
+ create("release") {
+ storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ }
+ }
}
-
- signingConfigs {
- if (keystorePropertiesFile.exists()) {
- create("release") {
- storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
- storePassword = keystoreProperties["storePassword"] as String
- keyAlias = keystoreProperties["keyAlias"] as String
- keyPassword = keystoreProperties["keyPassword"] as String
- }
- }
- }
-
- buildTypes {
- release {
- isMinifyEnabled = false
- if (keystorePropertiesFile.exists()) {
- signingConfig = signingConfigs.getByName("release")
- }
- proguardFiles(
- getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
- )
- }
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
- }
-
- kotlinOptions {
- jvmTarget = "1.8"
+
+ buildTypes {
+ debug {
+ // Still shrink so debug APKs used for sideload stay small/fast.
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ if (keystorePropertiesFile.exists()) {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
}
packaging {
resources {
merges += "META-INF/xposed/*"
+ excludes +=
+ setOf(
+ "/META-INF/{AL2.0,LGPL2.1}",
+ "META-INF/*.kotlin_module",
+ "META-INF/*.version",
+ "META-INF/LICENSE*",
+ "META-INF/NOTICE*",
+ "DebugProbesKt.bin",
+ "kotlin-tooling-metadata.json"
+ )
}
}
}
-
-dependencies {
- implementation("androidx.core:core-ktx:1.12.0")
- implementation("androidx.appcompat:appcompat:1.6.1")
- implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
-
- implementation("com.squareup.okhttp3:okhttp:4.12.0")
-
- compileOnly("io.github.libxposed:api:101.0.1")
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2025.02.00")
+ implementation(composeBom)
+
+ implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.activity:activity-compose:1.9.3")
+ // Prefer lighter lifecycle API used via ProcessLifecycle / Activity — compose helper is tiny
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.foundation:foundation")
+ implementation("androidx.compose.runtime:runtime")
+ implementation("androidx.compose.material3:material3")
+ // IMPORTANT: do NOT add material-icons-extended (tens of MB of icon vectors in DEX)
+
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+
+ compileOnly("io.github.libxposed:api:101.0.1")
compileOnly("de.robv.android.xposed:api:82")
-
- testImplementation("junit:junit:4.13.2")
-}
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-tooling-preview")
+ testImplementation("junit:junit:4.13.2")
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index e683e01..11f3d36 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,24 +1,38 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-
-# Keep Xposed API
--keep class de.robv.android.xposed.** { *; }
-
-# Keep our module class
--keep class io.github.recloudstudio.sleepyxposed.** { *; }
-
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
-
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
-
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
+# ---- SleepyXposed R8 rules ----
+# Do NOT keep the whole app package; that prevents shrinking Compose / Material.
+
+# libxposed / legacy Xposed entry (compileOnly — names kept for runtime)
+-keep class io.github.recloudstudio.sleepyxposed.ModuleMain { *; }
+-keep class * extends io.github.libxposed.api.XposedModule { *; }
+
+# Components declared in the manifest
+-keep class io.github.recloudstudio.sleepyxposed.MainActivity { *; }
+-keep class io.github.recloudstudio.sleepyxposed.ConfigContentProvider { *; }
+-keep class io.github.recloudstudio.sleepyxposed.MediaListenerService { *; }
+
+# Optional hook target for LSPosed (must keep signature)
+-keep class io.github.recloudstudio.sleepyxposed.XposedProbe {
+ public static boolean isModuleActive();
+}
+
+# Config model used across processes / JSON
+-keepclassmembers class io.github.recloudstudio.sleepyxposed.SleepyConfig { *; }
+-keepclassmembers class io.github.recloudstudio.sleepyxposed.MediaMethod { *; }
+
+# Hook / system-server logic must not be stripped (reflection + Xposed)
+-keep class io.github.recloudstudio.sleepyxposed.ConfigManager { *; }
+-keep class io.github.recloudstudio.sleepyxposed.ForegroundAppMonitor { *; }
+-keep class io.github.recloudstudio.sleepyxposed.MediaStatusMonitor { *; }
+-keep class io.github.recloudstudio.sleepyxposed.SleepyApiClient { *; }
+-keep class io.github.recloudstudio.sleepyxposed.RomDetector { *; }
+
+# Reflection in ConfigManager (XSharedPreferences)
+-dontwarn de.robv.android.xposed.**
+-dontwarn io.github.libxposed.**
+
+# Compose / Kotlin
+-dontwarn org.jetbrains.annotations.**
+
+# Keep line numbers for crash logs (small cost)
+-keepattributes SourceFile,LineNumberTable
+-renamesourcefileattribute SourceFile
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 92e1344..7f9d0ed 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,29 +4,38 @@
+
+
+
+
+
+
+
+
+ android:hardwareAccelerated="true"
+ android:theme="@style/Theme.SleepyXposed">
-
+ android:theme="@style/Theme.SleepyXposed"
+ android:windowSoftInputMode="adjustResize">
-
-
+
-
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/ConfigContentProvider.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ConfigContentProvider.kt
index 0e3760f..ec132af 100644
--- a/app/src/main/java/io/github/recloudstudio/sleepyxposed/ConfigContentProvider.kt
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ConfigContentProvider.kt
@@ -6,17 +6,25 @@ import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Binder
+import android.os.Bundle
import android.os.Process
/**
- * Exports module configuration to the system_server process.
+ * Exports module configuration to the system_server process, and receives liveness pings back
+ * from it (see [call] / [HookHeartbeat]).
*
* Modern Android SELinux blocks system_server from reading another app's private
* [android.content.SharedPreferences] / data dirs. Legacy [de.robv.android.xposed.XSharedPreferences]
- * also often fails under libxposed API 101. A ContentProvider query from the system UID works
- * because the framework starts this app process and reads prefs with the app's own identity.
+ * also often fails under libxposed API 101+. A ContentProvider query from the system UID works
+ * because the framework starts this app process and reads prefs with the app's own identity —
+ * this is a normal Binder call, not a raw filesystem access, so it isn't subject to external
+ * storage / SELinux write restrictions the way a system_server file write would be.
*
- * Only the system UID (and this app) may query; other callers are rejected.
+ * [call] reuses that same proven channel for the opposite direction: system_server pings this
+ * provider to prove a hook fired, and the app process (which hosts this provider) records that
+ * itself, entirely inside its own sandbox — no cross-UID filesystem write required.
+ *
+ * Only the system UID (and this app) may query/call; other callers are rejected.
*/
class ConfigContentProvider : ContentProvider() {
@@ -34,7 +42,7 @@ class ConfigContentProvider : ContentProvider() {
val config = ConfigManager.loadConfig(ctx)
val cursor = MatrixCursor(COLUMNS)
cursor.addRow(
- arrayOf(
+ arrayOf(
config.serverUrl,
config.secret,
config.deviceId,
@@ -49,6 +57,15 @@ class ConfigContentProvider : ContentProvider() {
return cursor
}
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
+ if (method == METHOD_HEARTBEAT) {
+ enforceSystemServerOnly()
+ context?.let { HookHeartbeat.recordPing(it, arg.orEmpty(), extras) }
+ return Bundle.EMPTY
+ }
+ return super.call(method, arg, extras)
+ }
+
override fun getType(uri: Uri): String = "vnd.android.cursor.item/vnd.$AUTHORITY.config"
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
@@ -64,26 +81,48 @@ class ConfigContentProvider : ContentProvider() {
private fun enforceSystemOrSelf() {
val uid = Binder.getCallingUid()
- if (uid != Process.SYSTEM_UID && uid != Process.myUid() && uid != 0) {
+ if (uid != Process.SYSTEM_UID && uid != Process.myUid()) {
throw SecurityException("SleepyXposed config is only readable by system")
}
}
+ /** Only system_server may record a heartbeat; a root caller must not be able to fake liveness. */
+ private fun enforceSystemServerOnly() {
+ val uid = Binder.getCallingUid()
+ if (uid != Process.SYSTEM_UID) {
+ throw SecurityException("SleepyXposed heartbeat is only writable by system")
+ }
+ }
+
companion object {
const val AUTHORITY = "io.github.recloudstudio.sleepyxposed.config"
val CONTENT_URI: Uri = Uri.parse("content://$AUTHORITY/config")
+ const val METHOD_HEARTBEAT = "heartbeat"
+ const val EXTRA_FRAMEWORK_NAME = "framework_name"
+ const val EXTRA_FRAMEWORK_VERSION = "framework_version"
+ const val EXTRA_FRAMEWORK_VERSION_CODE = "framework_version_code"
+
+ const val COLUMN_SERVER_URL = "server_url"
+ const val COLUMN_SECRET = "secret"
+ const val COLUMN_DEVICE_ID = "device_id"
+ const val COLUMN_SHOW_NAME = "show_name"
+ const val COLUMN_ENABLED = "enabled"
+ const val COLUMN_MEDIA_ENABLED = "media_enabled"
+ const val COLUMN_MEDIA_DEVICE_ID = "media_device_id"
+ const val COLUMN_MEDIA_SHOW_NAME = "media_show_name"
+ const val COLUMN_MEDIA_METHOD = "media_method"
val COLUMNS =
arrayOf(
- "server_url",
- "secret",
- "device_id",
- "show_name",
- "enabled",
- "media_enabled",
- "media_device_id",
- "media_show_name",
- "media_method"
+ COLUMN_SERVER_URL,
+ COLUMN_SECRET,
+ COLUMN_DEVICE_ID,
+ COLUMN_SHOW_NAME,
+ COLUMN_ENABLED,
+ COLUMN_MEDIA_ENABLED,
+ COLUMN_MEDIA_DEVICE_ID,
+ COLUMN_MEDIA_SHOW_NAME,
+ COLUMN_MEDIA_METHOD
)
}
}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/ConfigManager.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ConfigManager.kt
index 9726026..9050a81 100644
--- a/app/src/main/java/io/github/recloudstudio/sleepyxposed/ConfigManager.kt
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ConfigManager.kt
@@ -2,6 +2,7 @@ package io.github.recloudstudio.sleepyxposed
import android.content.Context
import android.os.Environment
+import android.os.SystemClock
import java.io.File
import org.json.JSONObject
@@ -20,7 +21,16 @@ data class SleepyConfig(
val mediaShowName: String = "",
/** Name of the [MediaMethod] used to acquire media playback status. */
val mediaMethod: String = MediaMethod.AUTO.name
-)
+) {
+ fun hasRequiredFields(): Boolean {
+ return serverUrl.isNotBlank() && secret.isNotBlank() && deviceId.isNotBlank() && showName.isNotBlank()
+ }
+
+ /** Complete enough for the public JSON mirror, which deliberately excludes [secret]. */
+ fun hasRequiredPublicFields(): Boolean {
+ return serverUrl.isNotBlank() && deviceId.isNotBlank() && showName.isNotBlank()
+ }
+}
/** Configuration manager for loading and saving config across app + system_server. */
object ConfigManager {
@@ -38,6 +48,13 @@ object ConfigManager {
private const val FALLBACK_DIR = "SleepyXposed"
private const val FALLBACK_FILE_NAME = "config.json"
+ // Hooked-process config reads happen on a hot path (MediaStatusMonitor polls every few
+ // seconds). Config only changes when the user hits Save, so memoize it for a short window
+ // instead of re-running a ContentProvider IPC + filesystem fallback chain on every poll.
+ private const val SYSTEM_CACHE_TTL_MS = 15_000L
+ @Volatile private var cachedSystemConfig: SleepyConfig? = null
+ @Volatile private var cachedSystemConfigAt: Long = 0L
+
/** Load configuration for module app process */
fun loadConfig(context: Context): SleepyConfig {
return try {
@@ -47,67 +64,59 @@ object ConfigManager {
val ce = readConfigFromPrefs(context, requireComplete = false)
if (ce != null && ce.hasRequiredFields()) return ce
- loadConfigFromJsonFiles(context)
- ?: de
- ?: ce
- ?: SleepyConfig()
+ loadConfigFromJsonFiles(context) ?: de ?: ce ?: SleepyConfig()
} catch (_: Exception) {
loadConfigFromJsonFiles(context) ?: SleepyConfig()
}
}
/**
- * Load configuration inside hooked processes (typically system_server).
+ * Load configuration inside hooked processes (typically system_server), with a short-lived
+ * cache (see [SYSTEM_CACHE_TTL_MS]).
*
- * 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)
+ * Private app data is SELinux-blocked from system_server on modern ROMs, so the primary path
+ * is a [ConfigContentProvider] query (app process reads its own prefs on the system's behalf),
+ * with a public JSON file as fallback for the rare case a provider query can't be made.
*/
- fun loadConfigFromXSharedPreferences(systemContext: Context? = null): SleepyConfig {
- systemContext?.let { ctx ->
- loadViaContentProvider(ctx)?.takeIf { it.hasRequiredFields() }?.let {
- return it
- }
+ fun loadConfigFromXSharedPreferences(systemContext: Context? = null, forceRefresh: Boolean = false): SleepyConfig {
+ val now = SystemClock.elapsedRealtime()
+ val cached = cachedSystemConfig
+ if (!forceRefresh && cached != null && now - cachedSystemConfigAt < SYSTEM_CACHE_TTL_MS) {
+ return cached
}
- loadConfigFromJsonFiles(null)?.takeIf { it.hasRequiredFields() }?.let {
- return it
- }
+ val fresh = loadConfigFromXSharedPreferencesUncached(systemContext)
+ cachedSystemConfig = fresh
+ cachedSystemConfigAt = now
+ return fresh
+ }
- loadViaLegacyXSharedPreferences()?.takeIf { it.hasRequiredFields() }?.let {
- return it
- }
+ private fun loadConfigFromXSharedPreferencesUncached(systemContext: Context?): SleepyConfig {
+ val provider = systemContext?.let { loadViaContentProvider(it) }
+ if (provider != null && provider.hasRequiredFields()) return provider
- loadConfigFromPrefsXmlFiles()?.takeIf { it.hasRequiredFields() }?.let {
- return it
- }
+ val json = loadConfigFromJsonFiles(null)
+ if (json != null && json.hasRequiredPublicFields()) return json
+
+ loadViaLegacyXSharedPreferences()?.takeIf { it.hasRequiredFields() }?.let { return it }
+ loadConfigFromPrefsXmlFiles()?.let { return it }
- return loadViaContentProvider(systemContext)
- ?: loadConfigFromJsonFiles(null)
- ?: loadViaLegacyXSharedPreferences()
- ?: loadConfigFromPrefsXmlFiles()
- ?: SleepyConfig()
+ return provider ?: json ?: SleepyConfig()
}
- /** Human-readable diagnostics for why system_server cannot see config. */
+ /** Human-readable diagnostics for why system_server cannot see config. Diagnostic-only. */
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("; ")
+ return "provider=${provider?.let { if (it.hasRequiredFields()) "ok" else "incomplete" } ?: "fail"}; " +
+ "json=${json?.let { if (it.hasRequiredPublicFields()) "ok" else "incomplete" } ?: "fail"}; " +
+ "xsp=${xsp?.let { if (it.hasRequiredFields()) "ok" else "incomplete" } ?: "fail"}; " +
+ "prefsXml=${xml?.let { if (it.hasRequiredFields()) "ok" else "incomplete" } ?: "fail"}; " +
+ "jsonFiles=[${existing.ifBlank { "none" }}]"
}
private fun loadViaContentProvider(context: Context?): SleepyConfig? {
@@ -127,15 +136,17 @@ object ConfigManager {
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 }
+ serverUrl = str(ConfigContentProvider.COLUMN_SERVER_URL),
+ secret = str(ConfigContentProvider.COLUMN_SECRET),
+ deviceId = str(ConfigContentProvider.COLUMN_DEVICE_ID),
+ showName = str(ConfigContentProvider.COLUMN_SHOW_NAME),
+ enabled = bool(ConfigContentProvider.COLUMN_ENABLED),
+ mediaEnabled = bool(ConfigContentProvider.COLUMN_MEDIA_ENABLED),
+ mediaDeviceId = str(ConfigContentProvider.COLUMN_MEDIA_DEVICE_ID),
+ mediaShowName = str(ConfigContentProvider.COLUMN_MEDIA_SHOW_NAME),
+ mediaMethod =
+ str(ConfigContentProvider.COLUMN_MEDIA_METHOD)
+ .ifBlank { MediaMethod.AUTO.name }
)
}
} catch (_: Exception) {
@@ -143,6 +154,7 @@ object ConfigManager {
}
}
+ /** Legacy [de.robv.android.xposed.XSharedPreferences] read for older ROMs where the provider cannot be reached. */
private fun loadViaLegacyXSharedPreferences(): SleepyConfig? {
return try {
val clazz = Class.forName("de.robv.android.xposed.XSharedPreferences")
@@ -174,6 +186,7 @@ object ConfigManager {
}
}
+ /** Parse the app's SharedPreferences XML directly when other paths are SELinux-blocked. */
private fun loadConfigFromPrefsXmlFiles(): SleepyConfig? {
for (file in getModulePrefsXmlCandidates()) {
try {
@@ -237,9 +250,16 @@ object ConfigManager {
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).
+ // JSON mirror for system_server (private app data is SELinux-blocked from system).
val jsonSaved = saveConfigToJsonFiles(context, config)
+ // cachedSystemConfig is a process-local static: clearing it here only invalidates the
+ // copy in THIS process (the app). system_server's own copy refreshes on its next poll once
+ // the TTL window elapses; the notifyChange below lets a system-side ContentObserver hook
+ // into that (there is currently none registered), so the TTL is the effective bound there.
+ cachedSystemConfig = null
+ cachedSystemConfigAt = 0L
+
try {
context.contentResolver.notifyChange(ConfigContentProvider.CONTENT_URI, null)
} catch (_: Exception) {}
@@ -308,11 +328,12 @@ object ConfigManager {
return getPrimaryPublicConfigFile().absolutePath
}
- private fun configToJson(config: SleepyConfig): String {
+ /** [secret] is written only when [includeSecret] is true (private prefs); never to the public JSON mirror. */
+ private fun configToJson(config: SleepyConfig, includeSecret: Boolean = true): String {
return JSONObject()
.apply {
put(KEY_SERVER_URL, config.serverUrl)
- put(KEY_SECRET, config.secret)
+ if (includeSecret) put(KEY_SECRET, config.secret)
put(KEY_DEVICE_ID, config.deviceId)
put(KEY_SHOW_NAME, config.showName)
put(KEY_ENABLED, config.enabled)
@@ -344,7 +365,9 @@ object ConfigManager {
}
private fun saveConfigToJsonFiles(context: Context, config: SleepyConfig): Boolean {
- val json = configToJson(config)
+ // The public mirror never carries the secret — it authenticates device reports, so it must
+ // not be readable from a world-readable file. system_server gets it via the provider query.
+ val json = configToJson(config, includeSecret = false)
var any = false
for (file in getAllJsonCandidates(context)) {
try {
@@ -368,7 +391,8 @@ object ConfigManager {
try {
if (!file.exists() || !file.canRead()) continue
val config = parseConfigJson(file.readText()) ?: continue
- if (config.hasRequiredFields()) {
+ // The public mirror has no secret; only require the fields it actually carries.
+ if (config.hasRequiredPublicFields()) {
return config
}
} catch (_: Exception) {}
@@ -378,47 +402,38 @@ object ConfigManager {
private fun getPrimaryPublicConfigFile(): File {
return File(
- "/storage/emulated/0/Android/media/$MODULE_PACKAGE_NAME/$FALLBACK_DIR/$FALLBACK_FILE_NAME"
+ Environment.getExternalStorageDirectory(),
+ "Android/media/$MODULE_PACKAGE_NAME/$FALLBACK_DIR/$FALLBACK_FILE_NAME"
)
}
+ /**
+ * Two write/read targets only: the package-specific public media dir (no permission needed,
+ * readable by system_server), and the app's own external-files dir as a backup for ROMs where
+ * the first path behaves unexpectedly. Earlier revisions probed ~8 candidate paths per call
+ * (each a filesystem stat); that cost was paid on every config read/write for no measurable
+ * reliability gain, so it has been trimmed down to these two.
+ */
private fun getAllJsonCandidates(context: Context?): List {
val files = linkedSetOf()
-
- // 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")
- )
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) {}
+ } else {
+ val externalRoot = Environment.getExternalStorageDirectory()
+ files.add(
+ File(
+ externalRoot,
+ "Android/data/$MODULE_PACKAGE_NAME/files/$FALLBACK_DIR/$FALLBACK_FILE_NAME"
+ )
+ )
}
- 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/data/$MODULE_PACKAGE_NAME/files/$FALLBACK_DIR/$FALLBACK_FILE_NAME"
- )
- )
-
return files.toList()
}
@@ -442,11 +457,4 @@ object ConfigManager {
// Best-effort
}
}
-
- private fun SleepyConfig.hasRequiredFields(): Boolean {
- return serverUrl.isNotBlank() &&
- secret.isNotBlank() &&
- deviceId.isNotBlank() &&
- showName.isNotBlank()
- }
}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/ForegroundAppMonitor.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ForegroundAppMonitor.kt
index 508f27c..60a6d37 100644
--- a/app/src/main/java/io/github/recloudstudio/sleepyxposed/ForegroundAppMonitor.kt
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ForegroundAppMonitor.kt
@@ -104,6 +104,10 @@ class ForegroundAppMonitor(private val log: (String) -> Unit) {
handler = Handler(looper)
loadConfiguration()
registerLockScreenReceiver()
+ // Prove the hook is alive as soon as bootstrap succeeds, even before the first
+ // foreground-app switch happens. Framework name/version rides along on every
+ // ping too (see HookHeartbeat.ping), so it gets the same automatic retry.
+ HookHeartbeat.ping(systemContext, "bootstrap")
}
} catch (e: Exception) {
log("$TAG: Failed to get system context: ${e.message}")
@@ -139,6 +143,8 @@ class ForegroundAppMonitor(private val log: (String) -> Unit) {
currentForegroundPackage = packageName
currentForegroundActivity = activityName
+ // Throttled internally — cheap to call on every switch.
+ HookHeartbeat.ping(systemContext, packageName)
if (packageName != lastForegroundPackage) {
lastForegroundPackage = packageName
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/HookHeartbeat.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/HookHeartbeat.kt
new file mode 100644
index 0000000..7c18b5b
--- /dev/null
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/HookHeartbeat.kt
@@ -0,0 +1,170 @@
+package io.github.recloudstudio.sleepyxposed
+
+import android.content.Context
+import android.os.Bundle
+import android.os.SystemClock
+
+/**
+ * Liveness signal written by the system_server-side hooks and read by the app UI to answer
+ * "is the Xposed hook actually running right now".
+ *
+ * A naive in-process activation probe cannot answer this: with the module's default LSPosed
+ * scope of `android` (system_server only), the app's own process is never touched by a hook, so
+ * a static in-process probe method has nothing to flip it to `true` and always reads `false`.
+ *
+ * A first version of this fix had system_server write a small file directly onto external
+ * storage. That looked right (the same directory is already used for [ConfigManager]'s JSON
+ * config mirror, which system_server reads fine) but silently failed: the directory was only
+ * ever `chmod`-ed readable+executable for other UIDs, never writable, and even with correct bits
+ * modern external storage is FUSE-emulated, where cross-UID *writes* into another app's
+ * package-specific directory aren't reliably honored by raw POSIX permissions the way *reads*
+ * are. Reads kept working (hence config/status reporting was fine); writes from system_server
+ * never landed, so the heartbeat file was never created.
+ *
+ * This version instead reuses [ConfigContentProvider]'s Binder channel — the same one config
+ * queries already go through successfully — by calling it with [ConfigContentProvider.METHOD_HEARTBEAT].
+ * The provider runs *inside the app's own process*, so [recordPing] just writes to the app's own
+ * private [android.content.SharedPreferences]: no cross-UID filesystem access at all, so nothing
+ * for storage sandboxing to block.
+ */
+object HookHeartbeat {
+ private const val PREFS_NAME = "sleepy_heartbeat"
+ private const val KEY_LAST_SEEN_MS = "last_seen_ms"
+ private const val KEY_LAST_DETAIL = "last_detail"
+ private const val KEY_FRAMEWORK_NAME = "framework_name"
+ private const val KEY_FRAMEWORK_VERSION = "framework_version"
+ private const val KEY_FRAMEWORK_VERSION_CODE = "framework_version_code"
+
+ /** Don't ping on every single call (e.g. every foreground-app switch) — only this often. */
+ private const val PING_THROTTLE_MS = 30_000L
+
+ /** UI treats the hook as active if it has heard from it within this window. */
+ private const val FRESHNESS_WINDOW_MS = 90_000L
+
+ @Volatile private var lastPingAtElapsed: Long = 0L
+
+ data class FrameworkInfo(val name: String, val version: String, val versionCode: Long)
+
+ /**
+ * Call from system_server-side code (via [systemContext]) whenever a hook demonstrably
+ * executes. Cheap: throttled, and the actual write happens in the app process via IPC.
+ *
+ * Opportunistically piggybacks the *actual* running framework's name/version (read straight
+ * from the libxposed `XposedInterface` this module is attached to, via [ModuleMain.instance])
+ * onto the same call. An earlier version pushed framework info exactly once, right when the
+ * hook first bootstraps — but if that single attempt lost the race (e.g. ran before the
+ * device's first unlock, when the app process/private storage may not be available yet), it
+ * was never retried and stayed stuck on "unknown" until the next reboot, unlike the heartbeat
+ * itself which self-heals because it's retried on every foreground switch / poll. Sending it
+ * with every (throttled) ping gives it the same automatic retry behavior.
+ */
+ fun ping(systemContext: Context?, detail: String = "") {
+ if (systemContext == null) return
+ val now = SystemClock.elapsedRealtime()
+ if (now - lastPingAtElapsed < PING_THROTTLE_MS) return
+ lastPingAtElapsed = now
+
+ try {
+ val extras = Bundle()
+ try {
+ val module = ModuleMain.instance
+ if (module != null) {
+ extras.putString(ConfigContentProvider.EXTRA_FRAMEWORK_NAME, module.getFrameworkName())
+ extras.putString(ConfigContentProvider.EXTRA_FRAMEWORK_VERSION, module.getFrameworkVersion())
+ extras.putLong(
+ ConfigContentProvider.EXTRA_FRAMEWORK_VERSION_CODE,
+ module.getFrameworkVersionCode()
+ )
+ }
+ } catch (_: Exception) {
+ // Framework info is a bonus; the heartbeat ping itself still proceeds without it.
+ }
+
+ systemContext.contentResolver.call(
+ ConfigContentProvider.CONTENT_URI,
+ ConfigContentProvider.METHOD_HEARTBEAT,
+ detail,
+ extras
+ )
+ } catch (_: Exception) {
+ // Best-effort; UI simply keeps showing "not detected" if this never lands.
+ }
+ }
+
+ /** Called by [ConfigContentProvider.call] inside the app process — plain private prefs. */
+ fun recordPing(appContext: Context, detail: String, extras: Bundle? = null) {
+ try {
+ appContext
+ .createDeviceProtectedStorageContext()
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putLong(KEY_LAST_SEEN_MS, System.currentTimeMillis())
+ .putString(KEY_LAST_DETAIL, detail)
+ .apply()
+ } catch (_: Exception) {
+ // Best-effort.
+ }
+
+ val name = extras?.getString(ConfigContentProvider.EXTRA_FRAMEWORK_NAME)
+ if (!name.isNullOrBlank()) {
+ recordFrameworkInfo(
+ appContext,
+ name = name,
+ version = extras.getString(ConfigContentProvider.EXTRA_FRAMEWORK_VERSION).orEmpty(),
+ versionCode = extras.getLong(ConfigContentProvider.EXTRA_FRAMEWORK_VERSION_CODE, 0L)
+ )
+ }
+ }
+
+ /** Called by [ConfigContentProvider.call] inside the app process — plain private prefs. */
+ fun recordFrameworkInfo(appContext: Context, name: String, version: String, versionCode: Long) {
+ if (name.isBlank()) return
+ try {
+ appContext
+ .createDeviceProtectedStorageContext()
+ .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putString(KEY_FRAMEWORK_NAME, name)
+ .putString(KEY_FRAMEWORK_VERSION, version)
+ .putLong(KEY_FRAMEWORK_VERSION_CODE, versionCode)
+ .apply()
+ } catch (_: Exception) {
+ // Best-effort.
+ }
+ }
+
+ /** Call from the app UI process. Null until the hook has pushed framework info at least once. */
+ fun frameworkInfo(context: Context): FrameworkInfo? {
+ return try {
+ val prefs =
+ context.createDeviceProtectedStorageContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val name = prefs.getString(KEY_FRAMEWORK_NAME, null)
+ if (name.isNullOrBlank()) return null
+ FrameworkInfo(
+ name = name,
+ version = prefs.getString(KEY_FRAMEWORK_VERSION, "").orEmpty(),
+ versionCode = prefs.getLong(KEY_FRAMEWORK_VERSION_CODE, 0L)
+ )
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ /** Call from the app UI process. */
+ fun isRecentlyActive(context: Context): Boolean {
+ val ago = lastSeenMillisAgo(context) ?: return false
+ return ago < FRESHNESS_WINDOW_MS
+ }
+
+ /** Milliseconds since the last heartbeat, or null if none has ever been recorded. */
+ fun lastSeenMillisAgo(context: Context): Long? {
+ return try {
+ val prefs =
+ context.createDeviceProtectedStorageContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val ts = prefs.getLong(KEY_LAST_SEEN_MS, 0L)
+ if (ts <= 0L) null else (System.currentTimeMillis() - ts).coerceAtLeast(0)
+ } catch (_: Exception) {
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/MainActivity.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/MainActivity.kt
index 9eeb11a..00e513e 100644
--- a/app/src/main/java/io/github/recloudstudio/sleepyxposed/MainActivity.kt
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/MainActivity.kt
@@ -1,172 +1,20 @@
package io.github.recloudstudio.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
-
-class MainActivity : AppCompatActivity() {
- private lateinit var serverUrlEdit: EditText
- private lateinit var secretEdit: EditText
- private lateinit var deviceIdEdit: EditText
- private lateinit var showNameEdit: EditText
- private lateinit var enabledSwitch: Switch
- 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
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import io.github.recloudstudio.sleepyxposed.ui.SleepyApp
+import io.github.recloudstudio.sleepyxposed.ui.SleepyTheme
+class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_main)
-
- 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 {
- loadConfiguration()
- updateRecommendationText()
- } catch (e: Exception) {
- Toast.makeText(this, "Error loading configuration: ${e.message}", Toast.LENGTH_LONG)
- .show()
- e.printStackTrace()
- }
- }
-
- private fun loadConfiguration() {
- val config = ConfigManager.loadConfig(this)
-
- serverUrlEdit.setText(config.serverUrl)
- secretEdit.setText(config.secret)
- 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
+ enableEdgeToEdge()
+ setContent {
+ SleepyTheme {
+ SleepyApp()
}
- mediaMethodGroup.check(radioId)
- }
-
- private fun saveConfiguration() {
- val url = serverUrlEdit.text.toString()
- val secret = secretEdit.text.toString()
- val id = deviceIdEdit.text.toString()
- val showName = showNameEdit.text.toString()
- val enabled = enabledSwitch.isChecked
-
- if (url.isEmpty() || secret.isEmpty() || id.isEmpty() || showName.isEmpty()) {
- Toast.makeText(this, getString(R.string.fill_all_fields), Toast.LENGTH_SHORT).show()
- 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(
- serverUrl = url,
- secret = secret,
- deviceId = id,
- showName = showName,
- enabled = enabled,
- mediaEnabled = mediaEnabled,
- mediaDeviceId = mediaDeviceId,
- mediaShowName = mediaShowName,
- mediaMethod = selectedMediaMethod().name
- )
-
- val success = ConfigManager.saveConfig(this, config)
-
- if (success) {
- // 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()
- } else {
- 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/io/github/recloudstudio/sleepyxposed/MediaListenerService.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaListenerService.kt
index 56169ce..ee922a2 100644
--- a/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaListenerService.kt
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaListenerService.kt
@@ -9,6 +9,8 @@ import android.media.session.PlaybackState
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
+import android.os.Handler
+import android.os.HandlerThread
import java.io.IOException
import okhttp3.Call
import okhttp3.Callback
@@ -35,14 +37,24 @@ class MediaListenerService : NotificationListenerService() {
private var mediaSessionManager: MediaSessionManager? = null
private var sessionsChangedListener: MediaSessionManager.OnActiveSessionsChangedListener? = null
private var lastStatus: String? = null
+ private var backgroundThread: HandlerThread? = null
+ private var backgroundHandler: Handler? = null
override fun onListenerConnected() {
super.onListenerConnected()
try {
+ backgroundThread = HandlerThread(TAG).also { it.start() }
+ backgroundHandler = Handler(backgroundThread!!.looper)
+
mediaSessionManager =
getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager
val componentName = ComponentName(this, MediaListenerService::class.java)
+ // Drop any listener registered by a previous connection so we never leak it.
+ sessionsChangedListener?.let {
+ mediaSessionManager?.removeOnActiveSessionsChangedListener(it)
+ }
+
val listener =
MediaSessionManager.OnActiveSessionsChangedListener { controllers ->
handleControllers(controllers)
@@ -60,6 +72,9 @@ class MediaListenerService : NotificationListenerService() {
super.onListenerDisconnected()
sessionsChangedListener?.let { mediaSessionManager?.removeOnActiveSessionsChangedListener(it) }
sessionsChangedListener = null
+ backgroundThread?.quitSafely()
+ backgroundThread = null
+ backgroundHandler = null
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
@@ -71,6 +86,10 @@ class MediaListenerService : NotificationListenerService() {
}
private fun handleControllers(controllers: List?) {
+ backgroundHandler?.post { handleControllersOnBackground(controllers) }
+ }
+
+ private fun handleControllersOnBackground(controllers: List?) {
val config = ConfigManager.loadConfig(this)
if (!config.enabled || !config.mediaEnabled) return
if (config.mediaDeviceId.isBlank() || config.mediaShowName.isBlank()) return
@@ -87,12 +106,13 @@ class MediaListenerService : NotificationListenerService() {
val status: String
val using: Boolean
+ val metadata = playing?.metadata
val title =
- playing?.metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)?.takeIf { it.isNotBlank() }
+ 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) ?: ""
+ metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST)
+ ?: metadata?.getString(MediaMetadata.METADATA_KEY_ALBUM_ARTIST) ?: ""
status = if (artist.isNotBlank()) "♪$title - $artist" else "♪$title"
using = true
} else {
@@ -100,8 +120,9 @@ class MediaListenerService : NotificationListenerService() {
using = false
}
+ // Only treat the status as delivered once the server confirms it; a failed send should
+ // not suppress the next attempt of the same status.
if (status == lastStatus) return
- lastStatus = status
try {
SleepyApiClient.sendDeviceStatus(
@@ -121,6 +142,8 @@ class MediaListenerService : NotificationListenerService() {
response.use {
if (!response.isSuccessful) {
Log.w(TAG, "Server error: ${response.code}")
+ } else {
+ lastStatus = status
}
}
}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaMethod.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaMethod.kt
index 9a28355..ad8a2d1 100644
--- a/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaMethod.kt
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaMethod.kt
@@ -22,6 +22,6 @@ enum class MediaMethod {
DUMPSYS_SHELL;
companion object {
- fun fromString(value: String?): MediaMethod = values().find { it.name == value } ?: AUTO
+ fun fromString(value: String?): MediaMethod = entries.find { it.name == value } ?: AUTO
}
}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaStatusMonitor.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaStatusMonitor.kt
index 2b03652..9c11001 100644
--- a/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaStatusMonitor.kt
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/MediaStatusMonitor.kt
@@ -5,10 +5,11 @@ import android.media.MediaMetadata
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import android.os.Handler
-import android.os.Looper
+import android.os.HandlerThread
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
+import java.util.concurrent.TimeUnit
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
@@ -34,6 +35,7 @@ class MediaStatusMonitor(private val log: (String) -> Unit) {
companion object {
private const val TAG = "SleepyXposed-Media"
private const val POLL_INTERVAL_MS = 8_000L
+ private const val DUMPSYS_TIMEOUT_SECONDS = 5L
private val DUMPSYS_DESCRIPTION_REGEX = Regex("description=([^,]+),\\s*([^,]+)")
private const val NOT_PLAYING_STATUS = "未在播放"
}
@@ -45,6 +47,7 @@ class MediaStatusMonitor(private val log: (String) -> Unit) {
private var pollRunnable: Runnable? = null
private var lastStatus: String? = null
private var lastSkipReason: String? = null
+ private var mediaSessionManager: MediaSessionManager? = null
fun initializeForSystemServer(classLoader: ClassLoader) {
log("$TAG: Initializing media status monitor...")
@@ -62,8 +65,11 @@ class MediaStatusMonitor(private val log: (String) -> Unit) {
return
}
- val looper = context.mainLooper ?: Looper.myLooper() ?: Looper.getMainLooper()
- handler = Handler(looper)
+ // Never run polling on the system_server main thread: both the MediaSessionManager
+ // query and the blocking dumpsys child process could stall it and trigger a Watchdog
+ // soft reboot. A dedicated HandlerThread keeps this work off the main looper.
+ val thread = HandlerThread("$TAG-poll").also { it.start() }
+ handler = Handler(thread.looper)
schedulePoll(POLL_INTERVAL_MS)
log("$TAG: Media status monitor initialized")
} catch (e: Exception) {
@@ -87,6 +93,10 @@ class MediaStatusMonitor(private val log: (String) -> Unit) {
}
private fun poll() {
+ // Throttled internally — proves the media hook side is alive regardless of whether
+ // media reporting itself is enabled/configured below.
+ HookHeartbeat.ping(systemContext)
+
val config = ConfigManager.loadConfigFromXSharedPreferences(systemContext)
if (!config.enabled) {
return
@@ -95,13 +105,13 @@ class MediaStatusMonitor(private val log: (String) -> Unit) {
return
}
if (config.mediaDeviceId.isBlank() || config.mediaShowName.isBlank()) {
- logSkipOnce("media device id / show name empty")
+ logSkipOnce("media device id / show name empty") { "media device id / show name empty" }
return
}
if (config.serverUrl.isBlank() || config.secret.isBlank()) {
- logSkipOnce(
+ logSkipOnce("server url / secret empty") {
"server url / secret empty — ${ConfigManager.describeLoadSources(systemContext)}"
- )
+ }
return
}
lastSkipReason = null
@@ -127,8 +137,9 @@ class MediaStatusMonitor(private val log: (String) -> Unit) {
using = false
}
+ // Only treat the status as delivered once the server confirms it; a failed send should
+ // not suppress the next poll of the same status.
if (status == lastStatus) return
- lastStatus = status
log("$TAG: Media status changed via $resolvedMethod: $status")
try {
@@ -149,6 +160,8 @@ class MediaStatusMonitor(private val log: (String) -> Unit) {
response.use {
if (!response.isSuccessful) {
log("$TAG: Server error: ${response.code}")
+ } else {
+ lastStatus = status
}
}
}
@@ -159,17 +172,19 @@ class MediaStatusMonitor(private val log: (String) -> Unit) {
}
}
- private fun logSkipOnce(reason: String) {
- if (lastSkipReason == reason) return
- lastSkipReason = reason
- log("$TAG: Media report skipped: $reason")
+ private fun logSkipOnce(reasonKey: String, message: () -> String) {
+ if (lastSkipReason == reasonKey) return
+ lastSkipReason = reasonKey
+ log("$TAG: Media report skipped: ${message()}")
}
private fun readViaMediaSessionManager(): MediaInfo? {
val context = systemContext ?: return null
return try {
val manager =
- context.getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager
+ mediaSessionManager
+ ?: (context.getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager)
+ ?.also { mediaSessionManager = it }
?: return null
val sessions = manager.getActiveSessions(null)
val playing =
@@ -195,22 +210,37 @@ class MediaStatusMonitor(private val log: (String) -> Unit) {
}
private fun readViaDumpsys(): MediaInfo? {
+ var process: Process? = null
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
+ process = ProcessBuilder("dumpsys", "media_session").redirectErrorStream(true).start()
+ // Bounded wait so a hung dumpsys can never block the (system_server) caller; the
+ // process is destroyed in the finally block on the way out.
+ if (!process.waitFor(DUMPSYS_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
+ log("$TAG: dumpsys media_session timed out")
+ return null
+ }
+ val output = BufferedReader(InputStreamReader(process.inputStream)).use { it.readText() }
+
+ // dumpsys prints one block per media session; the first description= in the whole
+ // dump may belong to a paused session. Select only the block that is actually
+ // PLAYING and extract its description, so we never report the wrong track.
+ val playingBlock =
+ output
+ .split(Regex("(?m)^\\s*#\\d+:\\s*MediaSession"))
+ .firstOrNull { block ->
+ block.contains("state=PLAYING") || block.contains("state=3")
+ }
+ ?: return null
- val match = DUMPSYS_DESCRIPTION_REGEX.find(output) ?: return null
+ val match = DUMPSYS_DESCRIPTION_REGEX.find(playingBlock) ?: 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
+ } finally {
+ process?.destroy()
}
}
}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/RomDetector.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/RomDetector.kt
index b27dcc5..865e7e8 100644
--- a/app/src/main/java/io/github/recloudstudio/sleepyxposed/RomDetector.kt
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/RomDetector.kt
@@ -1,7 +1,6 @@
package io.github.recloudstudio.sleepyxposed
import android.content.Context
-import android.content.pm.PackageManager
import android.os.Build
/**
@@ -34,27 +33,45 @@ object RomDetector {
val reason: String
)
+ // ROM identity and Android version are fixed for the process lifetime (a ROM/OS update
+ // requires a reboot, which restarts every process that would hold this cache). Detection
+ // involves several reflection + PackageManager calls, so memoizing avoids redoing that work
+ // on every poll cycle (previously recomputed every 8s from MediaStatusMonitor).
+ @Volatile private var cached: Recommendation? = null
+
/** Recommend a concrete method (never [MediaMethod.AUTO] or [MediaMethod.DUMPSYS_SHELL]). */
fun recommend(context: Context?): Recommendation {
+ cached?.let { return it }
+
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}」,其后台管控可能影响系统钩子的稳定性," +
- "推荐使用通知监听方式(需在本应用内手动授权一次通知访问权限)"
- )
+ val recommendation =
+ 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}」,其后台管控可能影响系统钩子的稳定性," +
+ "推荐使用通知监听方式(需在本应用内手动授权一次通知访问权限)"
+ )
+ }
+
+ // Only memoize once we have enough signal to be confident: with a null context the
+ // detection can only fall back to system properties and would conclude STOCK, which
+ // would poison later contextual calls. A non-STOCK rom detected via properties alone is
+ // definitive, so that's safe to cache too.
+ if (context != null || rom != RomFamily.STOCK) {
+ cached = recommendation
}
+ return recommendation
}
/** Resolve [MediaMethod.AUTO] to a concrete method; other values pass through unchanged. */
@@ -103,7 +120,7 @@ object RomDetector {
private fun hasPackage(context: Context, packageName: String): Boolean {
return try {
- context.packageManager.getPackageInfo(packageName, PackageManager.GET_META_DATA)
+ context.packageManager.getPackageInfo(packageName, 0)
true
} catch (_: Exception) {
false
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/StatusSnapshot.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/StatusSnapshot.kt
new file mode 100644
index 0000000..b86aa33
--- /dev/null
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/StatusSnapshot.kt
@@ -0,0 +1,141 @@
+package io.github.recloudstudio.sleepyxposed
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.provider.Settings
+import java.io.File
+
+/** Immutable dashboard snapshot — no Compose types. */
+data class StatusSnapshot(
+ val moduleHookActive: Boolean,
+ val lastHeartbeatAgoMs: Long?,
+ val reportingEnabled: Boolean,
+ val mediaReportingEnabled: Boolean,
+ val mediaMethod: String,
+ val notificationListenerEnabled: Boolean,
+ val configLooksComplete: Boolean,
+ val configPath: String,
+ val configPathExists: Boolean,
+ val androidVersion: String,
+ val apiLevel: Int,
+ val deviceModel: String,
+ val manufacturer: String,
+ val brand: String,
+ val romFamily: String,
+ val appVersionName: String,
+ val appVersionCode: Long,
+ val packageName: String,
+ val xposedFramework: String,
+ val xposedApiVersion: String,
+ val moduleChannel: String,
+ val deviceLine: String,
+ val systemLine: String
+) {
+ companion object {
+ fun collect(context: Context): StatusSnapshot {
+ val config =
+ runCatching { ConfigManager.loadConfig(context) }.getOrElse { SleepyConfig() }
+ val recommendation = runCatching { RomDetector.recommend(context) }.getOrNull()
+ val methodLabel =
+ when (MediaMethod.fromString(config.mediaMethod)) {
+ MediaMethod.AUTO -> context.getString(R.string.media_method_auto)
+ MediaMethod.SYSTEM_HOOK ->
+ context.getString(R.string.media_method_system_hook)
+ MediaMethod.NOTIFICATION_LISTENER ->
+ context.getString(R.string.media_method_notification_listener)
+ MediaMethod.DUMPSYS_SHELL ->
+ context.getString(R.string.media_method_dumpsys_shell)
+ }
+ val path = runCatching { ConfigManager.getConfigFilePath(context) }.getOrElse { "" }
+ val (verName, verCode) = appVersion(context)
+ val manufacturer = Build.MANUFACTURER.orEmpty()
+ val model = Build.MODEL.orEmpty()
+
+ return StatusSnapshot(
+ moduleHookActive = HookHeartbeat.isRecentlyActive(context) || XposedProbe.isModuleActive(),
+ lastHeartbeatAgoMs = HookHeartbeat.lastSeenMillisAgo(context),
+ reportingEnabled = config.enabled,
+ mediaReportingEnabled = config.mediaEnabled,
+ mediaMethod = methodLabel,
+ notificationListenerEnabled = isNotificationListenerEnabled(context),
+ configLooksComplete = config.hasRequiredFields(),
+ configPath = path,
+ configPathExists = path.isNotBlank() && File(path).exists(),
+ androidVersion = "Android ${Build.VERSION.RELEASE}",
+ apiLevel = Build.VERSION.SDK_INT,
+ deviceModel = model,
+ manufacturer = manufacturer,
+ brand = Build.BRAND.orEmpty(),
+ romFamily = recommendation?.rom?.displayName ?: "—",
+ appVersionName = verName,
+ appVersionCode = verCode,
+ packageName = context.packageName,
+ xposedFramework = detectXposedFramework(context),
+ xposedApiVersion = BuildConfig.XPOSED_API.toString(),
+ moduleChannel = BuildConfig.MODULE_CHANNEL,
+ deviceLine = listOf(manufacturer, model).filter { it.isNotBlank() }.joinToString(" "),
+ systemLine = "${Build.VERSION.RELEASE}(${Build.VERSION.SDK_INT})"
+ )
+ }
+
+ /**
+ * Read the framework name/version straight from [HookHeartbeat] — pushed by
+ * [ForegroundAppMonitor] directly from the libxposed `XposedInterface` this module is
+ * attached to (`getFrameworkName()` / `getFrameworkVersion()`). That's the authoritative
+ * source; it does not depend on whether a standalone LSPosed manager app happens to be
+ * installed (most people never install one), nor is it affected by package-visibility
+ * filtering (API 30+), which made an earlier "check if this package is installed"
+ * heuristic unreliable.
+ */
+ private fun detectXposedFramework(context: Context): String {
+ val info = HookHeartbeat.frameworkInfo(context) ?: return context.getString(R.string.status_framework_unknown)
+ return if (info.version.isNotBlank()) "${info.name} (${info.version})" else info.name
+ }
+
+ private fun appVersion(context: Context): Pair {
+ return try {
+ val pm = context.packageManager
+ val pi =
+ if (Build.VERSION.SDK_INT >= 33) {
+ pm.getPackageInfo(
+ context.packageName,
+ PackageManager.PackageInfoFlags.of(0)
+ )
+ } else {
+ @Suppress("DEPRECATION")
+ pm.getPackageInfo(context.packageName, 0)
+ }
+ val name = pi.versionName ?: "1.0"
+ val code =
+ if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode
+ else {
+ @Suppress("DEPRECATION")
+ pi.versionCode.toLong()
+ }
+ name to code
+ } catch (_: Exception) {
+ "1.0" to 1L
+ }
+ }
+
+ private fun isNotificationListenerEnabled(context: Context): Boolean {
+ return try {
+ val flat =
+ Settings.Secure.getString(
+ context.contentResolver,
+ "enabled_notification_listeners"
+ ) ?: return false
+ val pkg = context.packageName
+ val cn = ComponentName(context, MediaListenerService::class.java)
+ flat.split(':').any { entry ->
+ entry.equals(cn.flattenToString(), ignoreCase = true) ||
+ entry.startsWith("$pkg/")
+ }
+ } catch (_: Exception) {
+ false
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/XposedProbe.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/XposedProbe.kt
new file mode 100644
index 0000000..359388e
--- /dev/null
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/XposedProbe.kt
@@ -0,0 +1,17 @@
+package io.github.recloudstudio.sleepyxposed
+
+/**
+ * Application-process probe for module activation.
+ *
+ * Returns false when the method is not rewritten by a framework hook. If LSPosed scopes this
+ * package and an optional hook is installed, it can force the return value to true. With the
+ * current default scope (`android` / system_server only), the app process typically cannot prove
+ * activation; the UI still surfaces config + permission health as operational status.
+ *
+ * Do not rename or inline without considering external hooks.
+ */
+object XposedProbe {
+ @JvmStatic
+ @Suppress("FunctionOnlyReturningConstant")
+ fun isModuleActive(): Boolean = false
+}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/AppTheme.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/AppTheme.kt
new file mode 100644
index 0000000..5708ada
--- /dev/null
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/AppTheme.kt
@@ -0,0 +1,67 @@
+package io.github.recloudstudio.sleepyxposed.ui
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+// Static palette — no dynamic Monet / heavy theme controller.
+private val TealActive = Color(0xFF0D5C63)
+private val TealPrimary = Color(0xFF0D5C63)
+private val LinkBlue = Color(0xFF0A7EA4)
+private val BackgroundLight = Color(0xFFF5F6F8)
+private val SurfaceLight = Color(0xFFFFFFFF)
+private val OnSurface = Color(0xFF1C1B1F)
+private val OnSurfaceVariant = Color(0xFF6B7280)
+private val Outline = Color(0xFFE5E7EB)
+private val NavSelectedContainer = Color(0xFFD6EAF0)
+
+private val LightColors =
+ lightColorScheme(
+ primary = TealPrimary,
+ onPrimary = Color.White,
+ primaryContainer = NavSelectedContainer,
+ onPrimaryContainer = TealPrimary,
+ secondary = LinkBlue,
+ onSecondary = Color.White,
+ background = BackgroundLight,
+ onBackground = OnSurface,
+ surface = SurfaceLight,
+ onSurface = OnSurface,
+ surfaceVariant = Color(0xFFF0F1F3),
+ onSurfaceVariant = OnSurfaceVariant,
+ outline = Outline,
+ error = Color(0xFFB3261E)
+ )
+
+private val DarkColors =
+ darkColorScheme(
+ primary = Color(0xFF7EC8CF),
+ onPrimary = Color(0xFF00363A),
+ primaryContainer = Color(0xFF1A3A3E),
+ onPrimaryContainer = Color(0xFFB5E4E8),
+ secondary = Color(0xFF8EC8DC),
+ onSecondary = Color(0xFF003544),
+ background = Color(0xFF121416),
+ onBackground = Color(0xFFE4E2E6),
+ surface = Color(0xFF1C1E21),
+ onSurface = Color(0xFFE4E2E6),
+ surfaceVariant = Color(0xFF2A2D31),
+ onSurfaceVariant = Color(0xFFC4C7CC),
+ outline = Color(0xFF3D4248),
+ error = Color(0xFFF2B8B5)
+ )
+
+val StatusActiveColor = TealActive
+val StatusInactiveColor = Color(0xFF6B7280)
+
+@Composable
+fun SleepyTheme(content: @Composable () -> Unit) {
+ val dark = isSystemInDarkTheme()
+ MaterialTheme(
+ colorScheme = if (dark) DarkColors else LightColors,
+ content = content
+ )
+}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/ConfigScreen.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/ConfigScreen.kt
new file mode 100644
index 0000000..e9f396e
--- /dev/null
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/ConfigScreen.kt
@@ -0,0 +1,425 @@
+package io.github.recloudstudio.sleepyxposed.ui
+
+import android.content.Intent
+import android.provider.Settings
+import android.widget.Toast
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.github.recloudstudio.sleepyxposed.ConfigManager
+import io.github.recloudstudio.sleepyxposed.MediaMethod
+import io.github.recloudstudio.sleepyxposed.R
+import io.github.recloudstudio.sleepyxposed.RomDetector
+import io.github.recloudstudio.sleepyxposed.SleepyConfig
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** Lightweight Material3 settings form (no Miuix preference animations). */
+@Composable
+fun ConfigScreen() {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ // rememberSaveable keeps the user's in-progress edits across tab switches (the screen leaves
+ // composition when the tab changes). mediaMethod is stored as its String name since the enum
+ // isn't saveable; the `initialized` flag stops the async load from clobbering those edits.
+ var serverUrl by rememberSaveable { mutableStateOf("") }
+ var secret by rememberSaveable { mutableStateOf("") }
+ var deviceId by rememberSaveable { mutableStateOf("") }
+ var showName by rememberSaveable { mutableStateOf("") }
+ var enabled by rememberSaveable { mutableStateOf(false) }
+ var mediaEnabled by rememberSaveable { mutableStateOf(false) }
+ var mediaDeviceId by rememberSaveable { mutableStateOf("") }
+ var mediaShowName by rememberSaveable { mutableStateOf("") }
+ var mediaMethodName by rememberSaveable { mutableStateOf(MediaMethod.AUTO.name) }
+ var statusMessage by rememberSaveable { mutableStateOf(null) }
+ var initialized by rememberSaveable { mutableStateOf(false) }
+
+ // Load the (disk / provider-backed) config off the main thread once, then seed the fields.
+ LaunchedEffect(Unit) {
+ if (initialized) return@LaunchedEffect
+ val loaded =
+ withContext(Dispatchers.IO) {
+ runCatching { ConfigManager.loadConfig(context) }.getOrElse { SleepyConfig() }
+ }
+ serverUrl = loaded.serverUrl
+ secret = loaded.secret
+ deviceId = loaded.deviceId
+ showName = loaded.showName
+ enabled = loaded.enabled
+ mediaEnabled = loaded.mediaEnabled
+ mediaDeviceId = loaded.mediaDeviceId
+ mediaShowName = loaded.mediaShowName
+ mediaMethodName = loaded.mediaMethod.ifBlank { MediaMethod.AUTO.name }
+ initialized = true
+ }
+
+ var recommendationText by remember { mutableStateOf(null) }
+ LaunchedEffect(Unit) {
+ recommendationText =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val recommendation = RomDetector.recommend(context)
+ val methodLabel =
+ when (recommendation.method) {
+ MediaMethod.SYSTEM_HOOK ->
+ context.getString(R.string.media_method_system_hook)
+ MediaMethod.NOTIFICATION_LISTENER ->
+ context.getString(R.string.media_method_notification_listener)
+ else -> context.getString(R.string.media_method_system_hook)
+ }
+ context.getString(
+ R.string.media_recommendation_format,
+ recommendation.androidVersion,
+ recommendation.rom.displayName,
+ methodLabel,
+ recommendation.reason
+ )
+ }
+ .getOrNull()
+ }
+ }
+
+ val methodTitles =
+ remember {
+ MediaMethod.entries.associateWith { method ->
+ when (method) {
+ MediaMethod.AUTO -> context.getString(R.string.media_method_auto)
+ MediaMethod.SYSTEM_HOOK ->
+ context.getString(R.string.media_method_system_hook)
+ MediaMethod.NOTIFICATION_LISTENER ->
+ context.getString(R.string.media_method_notification_listener)
+ MediaMethod.DUMPSYS_SHELL ->
+ context.getString(R.string.media_method_dumpsys_shell)
+ }
+ }
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 20.dp)
+ .padding(top = 8.dp, bottom = 24.dp)
+ ) {
+ Spacer(Modifier.height(28.dp))
+ Text(
+ text = stringResource(R.string.settings_title),
+ fontSize = 34.sp,
+ fontWeight = FontWeight.Normal,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.config_description),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(Modifier.height(20.dp))
+
+ SectionTitle(stringResource(R.string.server_configuration))
+ SettingsCard {
+ SwitchRow(
+ title = stringResource(R.string.enable_reporting),
+ checked = enabled,
+ onCheckedChange = { enabled = it }
+ )
+ Field(
+ value = serverUrl,
+ onValueChange = { serverUrl = it },
+ label = stringResource(R.string.server_url_label),
+ keyboardType = KeyboardType.Uri
+ )
+ Field(
+ value = secret,
+ onValueChange = { secret = it },
+ label = stringResource(R.string.server_secret_label),
+ isPassword = true
+ )
+ Field(
+ value = deviceId,
+ onValueChange = { deviceId = it },
+ label = stringResource(R.string.device_id_label)
+ )
+ Field(
+ value = showName,
+ onValueChange = { showName = it },
+ label = stringResource(R.string.display_name_label)
+ )
+ }
+
+ Spacer(Modifier.height(16.dp))
+ SectionTitle(stringResource(R.string.media_section_title))
+ SettingsCard {
+ Text(
+ text = stringResource(R.string.media_section_description),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ SwitchRow(
+ title = stringResource(R.string.media_enable_reporting),
+ checked = mediaEnabled,
+ onCheckedChange = { mediaEnabled = it }
+ )
+ Field(
+ value = mediaDeviceId,
+ onValueChange = { mediaDeviceId = it },
+ label = stringResource(R.string.media_device_id_label)
+ )
+ Field(
+ value = mediaShowName,
+ onValueChange = { mediaShowName = it },
+ label = stringResource(R.string.media_show_name_label)
+ )
+ Text(
+ text = stringResource(R.string.media_method_label),
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(top = 4.dp, bottom = 4.dp)
+ )
+ Column(modifier = Modifier.selectableGroup()) {
+ MediaMethod.entries.forEach { method ->
+ val title = methodTitles[method].orEmpty()
+ val selected = mediaMethodName == method.name
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = selected,
+ onClick = { mediaMethodName = method.name },
+ role = Role.RadioButton
+ )
+ .height(48.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selected,
+ onClick = null
+ )
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(start = 4.dp)
+ )
+ }
+ }
+ }
+ recommendationText?.let { recommendation ->
+ Text(
+ text = recommendation,
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ }
+ OutlinedButton(
+ onClick = {
+ try {
+ context.startActivity(
+ Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
+ )
+ } catch (e: Exception) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.open_settings_failed, e.message),
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.media_grant_notification_access))
+ }
+ }
+
+ Spacer(Modifier.height(20.dp))
+ Button(
+ onClick = {
+ if (serverUrl.isBlank() ||
+ secret.isBlank() ||
+ deviceId.isBlank() ||
+ showName.isBlank()
+ ) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.fill_all_fields),
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ return@Button
+ }
+ if (mediaEnabled && (mediaDeviceId.isBlank() || mediaShowName.isBlank())) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.media_fill_required_fields),
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ return@Button
+ }
+ val config =
+ SleepyConfig(
+ serverUrl = serverUrl.trim(),
+ secret = secret,
+ deviceId = deviceId.trim(),
+ showName = showName.trim(),
+ enabled = enabled,
+ mediaEnabled = mediaEnabled,
+ mediaDeviceId = mediaDeviceId.trim(),
+ mediaShowName = mediaShowName.trim(),
+ mediaMethod = mediaMethodName
+ )
+ scope.launch {
+ val saved = withContext(Dispatchers.IO) { ConfigManager.saveConfig(context, config) }
+ if (saved) {
+ statusMessage =
+ context.getString(R.string.config_saved) +
+ "\n\n" +
+ ConfigManager.getConfigFilePath(context)
+ Toast.makeText(
+ context,
+ context.getString(R.string.config_saved_toast),
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ } else {
+ statusMessage = null
+ Toast.makeText(context, context.getString(R.string.config_save_failed), Toast.LENGTH_SHORT)
+ .show()
+ }
+ }
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(52.dp)
+ ) {
+ Text(stringResource(R.string.save_configuration))
+ }
+
+ if (statusMessage != null) {
+ Spacer(Modifier.height(12.dp))
+ SettingsCard {
+ Text(
+ text = statusMessage!!,
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+ SectionTitle(stringResource(R.string.instructions_title))
+ SettingsCard {
+ Text(
+ text = stringResource(R.string.instructions),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+}
+
+@Composable
+private fun SectionTitle(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+}
+
+@Composable
+private fun SettingsCard(content: @Composable () -> Unit) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors =
+ CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp), content = { content() })
+ }
+}
+
+@Composable
+private fun Field(
+ value: String,
+ onValueChange: (String) -> Unit,
+ label: String,
+ keyboardType: KeyboardType = KeyboardType.Text,
+ isPassword: Boolean = false
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ label = { Text(label) },
+ singleLine = true,
+ visualTransformation =
+ if (isPassword) PasswordVisualTransformation() else VisualTransformation.None,
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp)
+ )
+}
+
+@Composable
+private fun SwitchRow(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.weight(1f)
+ )
+ Switch(checked = checked, onCheckedChange = onCheckedChange)
+ }
+}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/OverviewScreen.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/OverviewScreen.kt
new file mode 100644
index 0000000..4f18b40
--- /dev/null
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/OverviewScreen.kt
@@ -0,0 +1,272 @@
+package io.github.recloudstudio.sleepyxposed.ui
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.github.recloudstudio.sleepyxposed.R
+import io.github.recloudstudio.sleepyxposed.StatusSnapshot
+
+/**
+ * HyperOShape-style overview: large title, solid status banner, simple key/value card.
+ * Icons use material-icons-core only (filled) to avoid multi‑MB icons-extended DEX.
+ */
+@Composable
+fun OverviewScreen(snapshot: StatusSnapshot?) {
+ var showMore by remember { mutableStateOf(false) }
+ // Single shared scroll state; no nested scrollables on this screen.
+ val scroll = rememberScrollState()
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(scroll, enabled = true)
+ .padding(horizontal = 20.dp)
+ .padding(top = 8.dp, bottom = 24.dp)
+ ) {
+ Spacer(Modifier.height(28.dp))
+ Text(
+ text = stringResource(R.string.app_name),
+ fontSize = 34.sp,
+ fontWeight = FontWeight.Normal,
+ color = MaterialTheme.colorScheme.onBackground,
+ letterSpacing = (-0.5).sp
+ )
+ Spacer(Modifier.height(24.dp))
+
+ if (snapshot == null) {
+ Text(
+ text = stringResource(R.string.status_loading),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ return@Column
+ }
+
+ StatusBanner(snapshot)
+ Spacer(Modifier.height(16.dp))
+ InfoCard(snapshot, showMore) { showMore = !showMore }
+ }
+}
+
+@Composable
+private fun StatusBanner(data: StatusSnapshot) {
+ val active = data.moduleHookActive
+ val bg = if (active) StatusActiveColor else StatusInactiveColor
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(18.dp),
+ color = bg,
+ shadowElevation = 0.dp,
+ tonalElevation = 0.dp
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 18.dp, vertical = 18.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = if (active) Icons.Filled.Check else Icons.Filled.Close,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(28.dp)
+ )
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(
+ text =
+ if (active) stringResource(R.string.status_banner_enabled)
+ else stringResource(R.string.status_banner_disabled),
+ color = Color.White,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text =
+ stringResource(
+ R.string.status_banner_version,
+ data.appVersionName,
+ data.xposedApiVersion,
+ data.appVersionCode
+ ),
+ color = Color.White.copy(alpha = 0.88f),
+ fontSize = 13.sp
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoCard(data: StatusSnapshot, showMore: Boolean, onToggleMore: () -> Unit) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(18.dp),
+ color = MaterialTheme.colorScheme.surface,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
+ shadowElevation = 0.dp
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp)) {
+ InfoItem(
+ label = stringResource(R.string.info_xposed_framework),
+ value = data.xposedFramework
+ )
+ InfoItem(
+ label = stringResource(R.string.info_xposed_api),
+ value = data.xposedApiVersion
+ )
+ InfoItem(
+ label = stringResource(R.string.info_module_channel),
+ value = data.moduleChannel
+ )
+ InfoItem(
+ label = stringResource(R.string.info_package_name),
+ value = data.packageName
+ )
+ InfoItem(
+ label = stringResource(R.string.info_device),
+ value = data.deviceLine.ifBlank { "—" }
+ )
+ InfoItem(
+ label = stringResource(R.string.info_system_version),
+ value = data.systemLine
+ )
+
+ // No AnimatedVisibility — animation during expand was extra cost; layout is the same.
+ if (showMore) {
+ InfoItem(
+ label = stringResource(R.string.status_last_heartbeat),
+ value = formatHeartbeatAge(data.lastHeartbeatAgoMs)
+ )
+ InfoItem(
+ label = stringResource(R.string.status_rom),
+ value = data.romFamily
+ )
+ InfoItem(
+ label = stringResource(R.string.status_reporting),
+ value =
+ if (data.reportingEnabled) stringResource(R.string.status_on)
+ else stringResource(R.string.status_off)
+ )
+ InfoItem(
+ label = stringResource(R.string.status_media_reporting),
+ value =
+ if (data.mediaReportingEnabled) stringResource(R.string.status_on)
+ else stringResource(R.string.status_off)
+ )
+ InfoItem(
+ label = stringResource(R.string.status_media_method),
+ value = data.mediaMethod
+ )
+ InfoItem(
+ label = stringResource(R.string.status_notification_listener),
+ value =
+ if (data.notificationListenerEnabled)
+ stringResource(R.string.status_granted)
+ else stringResource(R.string.status_not_granted)
+ )
+ InfoItem(
+ label = stringResource(R.string.status_config_complete),
+ value =
+ if (data.configLooksComplete) stringResource(R.string.status_ok)
+ else stringResource(R.string.status_incomplete)
+ )
+ if (data.configPath.isNotBlank()) {
+ InfoItem(
+ label = stringResource(R.string.status_config_path),
+ value =
+ data.configPath +
+ if (data.configPathExists) ""
+ else " (${stringResource(R.string.status_missing_file)})"
+ )
+ }
+ if (!data.moduleHookActive) {
+ Text(
+ text = stringResource(R.string.status_module_hook_hint),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(top = 4.dp, bottom = 8.dp)
+ )
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
+ TextButton(onClick = onToggleMore) {
+ Text(
+ text =
+ if (showMore) stringResource(R.string.less_info)
+ else stringResource(R.string.more_info),
+ color = MaterialTheme.colorScheme.secondary
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoItem(label: String, value: String) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp)
+ ) {
+ Text(
+ text = label,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Normal
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = value,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Normal,
+ lineHeight = 22.sp
+ )
+ }
+}
+
+/** "12s ago" / "3m ago" style summary; null/negative means we've never heard from the hook. */
+private fun formatHeartbeatAge(agoMs: Long?): String {
+ if (agoMs == null || agoMs < 0) return "—"
+ val seconds = agoMs / 1000
+ return when {
+ seconds < 60 -> "${seconds}s ago"
+ seconds < 3600 -> "${seconds / 60}m ago"
+ else -> "${seconds / 3600}h ago"
+ }
+}
diff --git a/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/SleepyApp.kt b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/SleepyApp.kt
new file mode 100644
index 0000000..6e40e48
--- /dev/null
+++ b/app/src/main/java/io/github/recloudstudio/sleepyxposed/ui/SleepyApp.kt
@@ -0,0 +1,114 @@
+package io.github.recloudstudio.sleepyxposed.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.NavigationBarItemDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import io.github.recloudstudio.sleepyxposed.R
+import io.github.recloudstudio.sleepyxposed.StatusSnapshot
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private enum class Tab {
+ Overview,
+ Config
+}
+
+@Composable
+fun SleepyApp() {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val scope = rememberCoroutineScope()
+ var tab by remember { mutableStateOf(Tab.Overview) }
+ var snapshot by remember { mutableStateOf(null) }
+ val navColors = NavigationBarItemDefaults.colors(
+ selectedIconColor = MaterialTheme.colorScheme.primary,
+ selectedTextColor = MaterialTheme.colorScheme.primary,
+ indicatorColor = MaterialTheme.colorScheme.primaryContainer,
+ unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ fun refresh() {
+ scope.launch {
+ snapshot = withContext(Dispatchers.Default) { StatusSnapshot.collect(context) }
+ }
+ }
+
+ DisposableEffect(lifecycleOwner) {
+ val observer =
+ LifecycleEventObserver { _, event ->
+ // Only refresh when returning to the activity (e.g. after system settings).
+ if (event == Lifecycle.Event.ON_RESUME) refresh()
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ Scaffold(
+ containerColor = MaterialTheme.colorScheme.background,
+ bottomBar = {
+ NavigationBar(
+ containerColor = MaterialTheme.colorScheme.surface,
+ tonalElevation = 0.dp
+ ) {
+ // Match HyperOShape: config left, overview (home) right
+ NavigationBarItem(
+ selected = tab == Tab.Config,
+ onClick = { tab = Tab.Config },
+ icon = {
+ Icon(
+ imageVector = Icons.Filled.Settings,
+ contentDescription = null
+ )
+ },
+ label = { Text(stringResource(R.string.tab_config)) },
+ colors = navColors
+ )
+ NavigationBarItem(
+ selected = tab == Tab.Overview,
+ onClick = { tab = Tab.Overview },
+ icon = {
+ Icon(
+ imageVector = Icons.Filled.Home,
+ contentDescription = null
+ )
+ },
+ label = { Text(stringResource(R.string.tab_overview)) },
+ colors = navColors
+ )
+ }
+ }
+ ) { padding ->
+ // Only the active tab is composed.
+ Box(Modifier.padding(padding)) {
+ when (tab) {
+ Tab.Overview -> OverviewScreen(snapshot = snapshot)
+ Tab.Config -> ConfigScreen()
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index 29284b3..0000000
--- a/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,271 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index fdf8df0..e7fc008 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -20,9 +20,44 @@
在 Sleepy 页面显示的名称
启用上报
保存配置
+ 设置说明
+ 配置
+ 概览
+ 配置
+ 更多信息
+ 收起
+ 已启用
+ 未检测到
+ 模块版本: %1$s-API%2$s(%3$d)
+ Xposed 框架
+ Xposed API 版本
+ 模块发行版本
+ 包名
+ 设备
+ 系统版本
+ 未知 / 未检测到
+ 正在加载状态…
+ 最近未收到钩子活动信号。请确认已在 LSPosed 中启用本模块并勾选「系统框架」作用域,然后重启设备——钩子开始正常工作后此处状态会自动更新。
+ 最近一次钩子活动
+ 开
+ 关
+ 完整
+ 未完成
+ 已授权
+ 未授权
+ 文件不存在
+ 应用状态上报
+ 媒体状态上报
+ 媒体获取方式
+ 通知访问权限
+ 配置完整性
+ 配置路径
+ ROM / 系统 UI
配置保存成功!\n\n请重启设备以使更改生效。
请填写所有字段
配置已保存
+ 配置保存失败
+ 无法打开设置:%1$s
SleepyXposed 媒体监听
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..e2ec588
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,16 @@
+
+
+
+ #FFF5F6F8
+ #FFFFFFFF
+ #FF1C1B1F
+ #FF6B7280
+ #FFE5E7EB
+ #FF0D5C63
+ #FF6B7280
+ #FFFFFFFF
+ #FF0D5C63
+ #FFD6EAF0
+ #FF0D5C63
+ #FF0A7EA4
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 976079c..bf3e180 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -22,10 +22,45 @@
Name shown on Sleepy page
Enable Reporting
Save Configuration
+ Setup notes
+ Configuration
+ Overview
+ Config
+ More info
+ Less info
+ Enabled
+ Not detected
+ Module version: %1$s-API%2$s(%3$d)
+ Xposed framework
+ Xposed API version
+ Module channel
+ Package name
+ Device
+ System version
+ Unknown / not detected
+ Loading status…
+ Not detected recently. Make sure the module is enabled in LSPosed with \'System Framework\' scope, then reboot — status here updates automatically once the hook reports in.
+ Last hook activity
+ On
+ Off
+ OK
+ Incomplete
+ Granted
+ Not granted
+ file missing
+ App status reporting
+ Media reporting
+ Media method
+ Notification access
+ Config completeness
+ Config path
+ ROM / System UI
Configuration saved successfully!\n\nPlease reboot your device for
changes to take effect.
Please fill in all fields
Configuration saved
+ Failed to save configuration
+ Failed to open settings: %1$s
SleepyXposed Media Listener
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..d3f3e22
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/build.gradle b/build.gradle
index 62a4b44..530eefb 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,10 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- id "com.android.application" version "8.1.0" apply false
- id "org.jetbrains.kotlin.android" version "1.9.0" apply false
+ id "com.android.application" version "8.9.1" apply false
+ id "org.jetbrains.kotlin.android" version "2.3.20" apply false
+ id "org.jetbrains.kotlin.plugin.compose" version "2.3.20" apply false
}
task clean(type: Delete) {
- delete rootProject.buildDir
+ delete rootProject.layout.buildDirectory
}
-
diff --git a/gradle.properties b/gradle.properties
index 2b7a6bc..b99beef 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,10 +1,14 @@
-# This file was generated by the Gradle 'init' task.
-# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties
-
-org.gradle.configuration-cache=true
-
-# AndroidX support
-android.useAndroidX=true
-android.enableJetifier=true
-
-android.suppressUnsupportedCompileSdk=34
+# This file was generated by the Gradle 'init' task.
+# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties
+
+org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC
+org.gradle.parallel=true
+# Configuration cache is disabled: the Android + R8 toolchain here has not been validated with it
+# enabled, and a cache mismatch would surface as confusing incremental-build failures.
+org.gradle.configuration-cache=false
+
+# AndroidX support
+android.useAndroidX=true
+android.enableJetifier=false
+
+android.suppressUnsupportedCompileSdk=35
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 1af9e09..37f853b 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/settings.gradle.kts b/settings.gradle.kts
deleted file mode 100644
index 0d1c1c7..0000000
--- a/settings.gradle.kts
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * This file was generated by the Gradle 'init' task.
- *
- * The settings file is used to specify which projects to include in your build.
- * For more detailed information on multi-project builds, please refer to https://docs.gradle.org/9.3.1/userguide/multi_project_builds.html in the Gradle documentation.
- */
-
-pluginManagement {
- repositories {
- google()
- mavenCentral()
- gradlePluginPortal()
- }
-}
-
-plugins {
- // Apply the foojay-resolver plugin to allow automatic download of JDKs
- id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
-}
-
-rootProject.name = "SleepyXposed"
-include("app")