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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -