diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts index 5349af3d3..fdfa94b7d 100644 --- a/manager/build.gradle.kts +++ b/manager/build.gradle.kts @@ -66,11 +66,18 @@ afterEvaluate { // asset merge names them directly: a dependency routed through an aggregator lifecycle task // carries no output and does not satisfy the input/output validation, which a parallel build // (CI) turns into a hard error rather than a warning. - tasks.named("merge${variantCapped}Assets") { - dependsOn(":meta-loader:copyDex$variantCapped") - dependsOn(":patch-loader:copyDex$variantCapped") - dependsOn(":patch-loader:copySo$variantCapped") - } + val loaderArtifacts = listOf( + ":meta-loader:copyDex$variantCapped", + ":patch-loader:copyDex$variantCapped", + ":patch-loader:copySo$variantCapped", + ) + tasks.named("merge${variantCapped}Assets") { dependsOn(loaderArtifacts) } + + // Lint reads that same directory to model the variant, and infers its producers no better than + // the asset merge does. Undeclared, the validation is a hard error rather than a warning, so + // `gradlew build` fails on a project that assembles perfectly well. + tasks.matching { it.name.contains("lint", ignoreCase = true) && it.name.contains(variantCapped) } + .configureEach { dependsOn(loaderArtifacts) } tasks.register("build$variantCapped") { dependsOn(tasks["assemble$variantCapped"]) diff --git a/manager/src/main/AndroidManifest.xml b/manager/src/main/AndroidManifest.xml index 1b7365551..2dd4dc69e 100644 --- a/manager/src/main/AndroidManifest.xml +++ b/manager/src/main/AndroidManifest.xml @@ -13,12 +13,18 @@ - + + + + + + + android:value="serving modules to patched apps and monitoring their logs" /> + + + + + + + + diff --git a/manager/src/main/aidl/org/lsposed/lspatch/IShizukuService.aidl b/manager/src/main/aidl/org/lsposed/lspatch/IShizukuService.aidl index c241118ec..65b023086 100644 --- a/manager/src/main/aidl/org/lsposed/lspatch/IShizukuService.aidl +++ b/manager/src/main/aidl/org/lsposed/lspatch/IShizukuService.aidl @@ -69,4 +69,20 @@ interface IShizukuService { // caller pushes the current set periodically so that app joins the framework stream in place. // Does nothing when no collector is running -- the next start carries the set anyway. void updateLogCollectorUids(in int[] relevantUids) = 11; + + // --- Keeping the manager reachable. This process runs as the shell user and is owned by the + // Shizuku server rather than by the manager, so it is not what a device's background reaper or a + // force-stop acts on -- which is the whole reason the watchdog lives here and not in the app. --- + + // Starts a supervisor that starts [component] (an "package/class" name, in [userId]) again + // whenever no process of [packageName] is running, checking every [intervalSeconds]. Started from + // the shell, that start also clears the stopped state a force-stop leaves behind, which nothing + // running inside the app can do. Replaces any watchdog already running. + boolean startManagerWatchdog(String packageName, String component, int userId, int intervalSeconds) = 14; + + // Stops the supervisor, if any. + void stopManagerWatchdog() = 15; + + // Whether a supervisor is currently running. + boolean isManagerWatchdogRunning() = 16; } diff --git a/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt b/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt index 32335b681..cc9d80a5b 100644 --- a/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt +++ b/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt @@ -13,7 +13,7 @@ import org.lsposed.hiddenapibypass.HiddenApiBypass import org.lsposed.lspatch.data.repository.PatchOutputStore import org.lsposed.lspatch.data.repository.PatchRequestStore import org.lsposed.lspatch.manager.AppBroadcastReceiver -import org.lsposed.lspatch.service.LogCollectorService +import org.lsposed.lspatch.service.ManagerResidentService import org.lsposed.lspatch.util.LSPPackageManager import org.lsposed.lspatch.util.ManagerMigrate import org.lsposed.lspatch.util.ShizukuApi @@ -72,8 +72,9 @@ class LSPApplication : Application() { // by someone; the app list is what says which packages still have a reason to keep theirs. globalScope.launch { PatchOutputStore.sweep() } globalScope.launch { PatchRequestStore.prune() } - // The service itself waits for Shizuku before starting the shell-side collector, and the - // start is guarded against the background foreground-service restriction. - LogCollectorService.start(this) + // The service keeps the manager reachable for patched apps, and collects logs once Shizuku is + // granted; it stands down on its own if nothing on this device is patched. The start is + // guarded against the background foreground-service restriction. + ManagerResidentService.start(this) } } diff --git a/manager/src/main/java/org/lsposed/lspatch/ShizukuService.kt b/manager/src/main/java/org/lsposed/lspatch/ShizukuService.kt index d240c9a81..c796baff5 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ShizukuService.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ShizukuService.kt @@ -396,9 +396,114 @@ class ShizukuService : IShizukuService.Stub() { override fun destroy() { Log.i(TAG, "Shell service destroyed") stopLogCollector() + stopManagerWatchdog() exitProcess(0) } + // --- Keeping the manager reachable --- + + @Volatile private var watchdog: Thread? = null + + /** + * What the running watchdog was asked to watch. + * + * The manager re-states its wish on every tick of its own supervisor loop -- it has to, because this process + * outlives it and may have been started since the last time it spoke -- so without this a thread would be torn down + * and built again every few seconds for no change at all. + */ + @Volatile private var watching: String? = null + + /** + * Starts the manager again whenever it is found gone. + * + * This runs as the shell user, in a process the Shizuku server owns rather than the manager: a device's background + * reaper and a force-stop both act on the manager's package and leave this one untouched, which is what makes a + * watchdog here able to do something no code inside the manager can. Starting a component from the shell also + * clears the stopped state a force-stop leaves behind, so the manager is not merely restarted but made reachable + * again. + * + * It gives up after [MAX_WATCHDOG_FAILURES] starts in a row that changed nothing -- the manager uninstalled, or a + * device that refuses the start outright -- rather than retrying forever at the cost of the battery it was meant to + * protect. + */ + @Synchronized + override fun startManagerWatchdog( + packageName: String, + component: String, + userId: Int, + intervalSeconds: Int, + ): Boolean { + val wanted = "$packageName|$component|$userId|$intervalSeconds" + if (wanted == watching && watchdog?.isAlive == true) return true + stopManagerWatchdog() + if (packageName.isEmpty() || component.isEmpty()) return false + watching = wanted + val interval = intervalSeconds.coerceIn(30, 3600) * 1000L + Log.i(TAG, "Watching $packageName; restarting $component (user $userId) every ${interval}ms") + val thread = Thread { + var failures = 0 + while (!Thread.currentThread().isInterrupted) { + try { + Thread.sleep(interval) + } catch (e: InterruptedException) { + return@Thread + } + if (isProcessRunning(packageName)) { + failures = 0 + continue + } + Log.i(TAG, "$packageName is not running; starting $component") + val output = runShellCommand("am start-foreground-service --user $userId -n $component") + // The command reports its own refusals, and they are the interesting case: a device + // that will not let the shell start this component says so here and nowhere else. + if (output.isNotBlank()) Log.i(TAG, "start-foreground-service: ${output.trim()}") + failures = if (isProcessRunning(packageName)) 0 else failures + 1 + if (failures >= MAX_WATCHDOG_FAILURES) { + Log.w(TAG, "Giving up on $packageName after $failures starts that changed nothing") + return@Thread + } + } + } + thread.isDaemon = true + thread.name = "lspatch-manager-watchdog" + watchdog = thread + thread.start() + return true + } + + @Synchronized + override fun stopManagerWatchdog() { + watchdog?.let { + Log.i(TAG, "Stopping the manager watchdog") + it.interrupt() + } + watchdog = null + watching = null + } + + override fun isManagerWatchdogRunning(): Boolean = watchdog?.isAlive == true + + /** + * Whether the manager's own process exists. + * + * By process name rather than by asking the activity manager: an app's main process is named after its package, the + * same `ps` this service already reads to reap its own strays, and it costs no privileged call. + * + * The match is exact, and that is the whole of it: this service runs as `:service`, so a prefix match + * would find *itself* and report the manager alive for as long as the watchdog that asked was running -- which is + * every moment it could ever have acted. + */ + private fun isProcessRunning(packageName: String): Boolean = runCatching { + Runtime.getRuntime().exec(arrayOf("sh", "-c", "ps -A -o NAME")).inputStream.bufferedReader().useLines { lines -> + lines.any { it.trim() == packageName } + } + } + .getOrElse { + Log.w(TAG, "Cannot tell whether $packageName is running", it) + // Assumed alive: a failed read must not turn into a restart the device did not need. + true + } + /** * A rotating writer for one stream. Lines append to `_.log` until it exceeds [MAX_PART_BYTES]; * then it opens a fresh timestamped part and prunes the oldest so at most [MAX_PARTS] survive per prefix. Not @@ -488,6 +593,9 @@ class ShizukuService : IShizukuService.Stub() { */ const val MAX_OUTPUT_CHARS = 128_000 + /** Consecutive restarts that changed nothing before the watchdog concludes it cannot help. */ + const val MAX_WATCHDOG_FAILURES = 5 + /** ~4 MB per part, eight parts per stream — ~32 MB of history apiece at most. */ const val MAX_PART_BYTES = 4L * 1024 * 1024 const val MAX_PARTS = 8 diff --git a/manager/src/main/java/org/lsposed/lspatch/config/ConfigManager.kt b/manager/src/main/java/org/lsposed/lspatch/config/ConfigManager.kt index 6d43dd8e7..b3afd19df 100644 --- a/manager/src/main/java/org/lsposed/lspatch/config/ConfigManager.kt +++ b/manager/src/main/java/org/lsposed/lspatch/config/ConfigManager.kt @@ -4,6 +4,7 @@ import android.content.pm.PackageManager import android.util.Log import androidx.room.Room import androidx.room.withTransaction +import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -16,20 +17,22 @@ import org.lsposed.lspatch.database.entity.Scope import org.lsposed.lspatch.lspApp import org.lsposed.lspatch.manager.ManagerRemoteServices import org.lsposed.lspatch.util.LSPPackageManager -import org.lsposed.lspatch.util.ModuleLoader +import org.lsposed.lspatch.util.LoadedModules import org.matrix.vector.ipc.LoadedModule -import java.io.File object ConfigManager { private const val TAG = "ConfigManager" - @OptIn(ExperimentalCoroutinesApi::class) - private val dispatcher = Dispatchers.Default.limitedParallelism(1) + @OptIn(ExperimentalCoroutinesApi::class) private val dispatcher = Dispatchers.Default.limitedParallelism(1) - private val db: LSPDatabase = Room.databaseBuilder( - lspApp, LSPDatabase::class.java, "modules_config.db" - ).build() + private val db: LSPDatabase = + Room.databaseBuilder( + lspApp, + LSPDatabase::class.java, + "modules_config.db", + ) + .build() private val moduleDao = db.moduleDao() private val scopeDao = db.scopeDao() @@ -43,7 +46,8 @@ object ConfigManager { // scope that points at it. Delete only when the package is genuinely gone. val stillInstalled = runCatching { lspApp.packageManager.getApplicationInfo(module.pkgName, 0) - }.isSuccess + } + .isSuccess if (!stillInstalled) moduleDao.delete(module) } for ((pkgName, apkPath) in newModules) { @@ -68,9 +72,8 @@ object ConfigManager { /** * Counts up whenever any app's module scope changes. * - * The scope lives in the database, which nothing observes; without a signal, editing an app's - * modules on one screen left the other screen showing the set from before the edit until the - * manager was restarted. + * The scope lives in the database, which nothing observes; without a signal, editing an app's modules on one screen + * left the other screen showing the set from before the edit until the manager was restarted. */ private val _scopeRevision = MutableStateFlow(0) val scopeRevision: StateFlow = _scopeRevision.asStateFlow() @@ -78,23 +81,25 @@ object ConfigManager { /** * Makes [modules] the complete set of modules enabled for [appPkgName], in one transaction. * - * All of it or none of it: a half-applied scope is a patched app running a module combination - * the user never chose. The parent [Module] row is ensured for every target first, because the - * scope table has a foreign key onto it and inserting a scope row alone fails for a module the - * manager has not catalogued yet. + * All of it or none of it: a half-applied scope is a patched app running a module combination the user never chose. + * The parent [Module] row is ensured for every target first, because the scope table has a foreign key onto it and + * inserting a scope row alone fails for a module the manager has not catalogued yet. */ suspend fun setScopeForApp(appPkgName: String, modules: Set): Result = withContext(dispatcher) { runCatching { + var before = emptySet() db.withTransaction { - val before = scopeDao.getModulesForApp(appPkgName).map { it.pkgName }.toSet() + before = scopeDao.getModulesForApp(appPkgName).map { it.pkgName }.toSet() (before - modules).forEach { scopeDao.delete(Scope(appPkgName = appPkgName, modulePkgName = it)) } (modules - before).forEach { pkg -> - val apkPath = runCatching { - lspApp.packageManager.getApplicationInfo(pkg, 0).sourceDir - }.getOrNull() ?: return@forEach + val apkPath = + runCatching { + lspApp.packageManager.getApplicationInfo(pkg, 0).sourceDir + } + .getOrNull() ?: return@forEach moduleDao.insert(Module(pkg, apkPath)) moduleDao.updatePath(pkg, apkPath) scopeDao.insert(Scope(appPkgName = appPkgName, modulePkgName = pkg)) @@ -102,6 +107,10 @@ object ConfigManager { } LSPPackageManager.invalidateModuleIcons(appPkgName) _scopeRevision.value++ + // Whoever gained or lost this app is now describing a different scope to its companion, + // and a companion holding no service at all is the common case; both are settled by a + // push, which reaches the module app whether or not it is already running. + ManagerRemoteServices.pushToCompanionsAsync(before + modules) Unit } } @@ -137,9 +146,9 @@ object ConfigManager { } /** - * A fresh [LoadedModule] for a single module by package, or null if it is not a [legacy]-matching - * module or cannot be loaded. Hot reload uses this to build the new generation from the module's - * currently installed apk, the same way [getModuleFilesForApp] builds the ones a host loads. + * A fresh [LoadedModule] for a single module by package, or null if it is not a [legacy]-matching module or cannot + * be loaded. Hot reload uses this to build the new generation from the module's currently installed apk, the same + * way [getModuleFilesForApp] builds the ones a host loads. */ suspend fun buildLoadedModule(pkgName: String, legacy: Boolean = false): LoadedModule? = withContext(dispatcher) { @@ -158,27 +167,26 @@ object ConfigManager { } Log.i(TAG, "Module apk path updated: ${module.pkgName}") } - val code = ModuleLoader.loadModule(module.apkPath) ?: return null - if (code.legacy != legacy) { - code.preLoadedDexes.forEach { dex -> runCatching { dex.close() } } - return null - } val pm = lspApp.packageManager - val appInfo = try { - pm.getApplicationInfo(module.pkgName, 0) - } catch (e: PackageManager.NameNotFoundException) { - null - } - return LoadedModule().apply { - packageName = module.pkgName - apkPath = module.apkPath - this.code = code - applicationInfo = appInfo - appId = (appInfo?.uid ?: -1).let { uid -> if (uid < 0) -1 else uid % 100000 } - versionCode = runCatching { - pm.getPackageInfo(module.pkgName, 0).longVersionCode - }.getOrDefault(0L) - service = ManagerRemoteServices.moduleService(module.pkgName) + val appInfo = + try { + pm.getApplicationInfo(module.pkgName, 0) + } catch (e: PackageManager.NameNotFoundException) { + null + } + val appId = (appInfo?.uid ?: -1).let { uid -> if (uid < 0) -1 else uid % 100000 } + val versionCode = runCatching { + pm.getPackageInfo(module.pkgName, 0).longVersionCode } + .getOrDefault(0L) + return LoadedModules.fromApk( + module.pkgName, + module.apkPath, + appId, + versionCode, + appInfo, + legacy, + ManagerRemoteServices.moduleService(module.pkgName), + ) } } diff --git a/manager/src/main/java/org/lsposed/lspatch/config/Configs.kt b/manager/src/main/java/org/lsposed/lspatch/config/Configs.kt index 8ca148e52..3ccb3c5b3 100644 --- a/manager/src/main/java/org/lsposed/lspatch/config/Configs.kt +++ b/manager/src/main/java/org/lsposed/lspatch/config/Configs.kt @@ -11,20 +11,48 @@ object Configs { private const val PREFS_KEYSTORE_ALIAS = "keystore_alias" private const val PREFS_KEYSTORE_ALIAS_PASSWORD = "keystore_alias_password" private const val PREFS_DETAIL_PATCH_LOGS = "detail_patch_logs" + private const val PREFS_KEEP_MANAGER_ALIVE = "keep_manager_alive" + private const val PREFS_ASKED_STAY_ALIVE = "asked_stay_alive" - var keyStorePassword by delegateStateOf(lspApp.prefs.getString(PREFS_KEYSTORE_PASSWORD, "123456")!!) { - lspApp.prefs.edit().putString(PREFS_KEYSTORE_PASSWORD, it).apply() - } + var keyStorePassword by + delegateStateOf(lspApp.prefs.getString(PREFS_KEYSTORE_PASSWORD, "123456")!!) { + lspApp.prefs.edit().putString(PREFS_KEYSTORE_PASSWORD, it).apply() + } - var keyStoreAlias by delegateStateOf(lspApp.prefs.getString(PREFS_KEYSTORE_ALIAS, "key0")!!) { - lspApp.prefs.edit().putString(PREFS_KEYSTORE_ALIAS, it).apply() - } + var keyStoreAlias by + delegateStateOf(lspApp.prefs.getString(PREFS_KEYSTORE_ALIAS, "key0")!!) { + lspApp.prefs.edit().putString(PREFS_KEYSTORE_ALIAS, it).apply() + } - var keyStoreAliasPassword by delegateStateOf(lspApp.prefs.getString(PREFS_KEYSTORE_ALIAS_PASSWORD, "123456")!!) { - lspApp.prefs.edit().putString(PREFS_KEYSTORE_ALIAS_PASSWORD, it).apply() - } + var keyStoreAliasPassword by + delegateStateOf(lspApp.prefs.getString(PREFS_KEYSTORE_ALIAS_PASSWORD, "123456")!!) { + lspApp.prefs.edit().putString(PREFS_KEYSTORE_ALIAS_PASSWORD, it).apply() + } - var detailPatchLogs by delegateStateOf(lspApp.prefs.getBoolean(PREFS_DETAIL_PATCH_LOGS, true)) { - lspApp.prefs.edit().putBoolean(PREFS_DETAIL_PATCH_LOGS, it).apply() - } + var detailPatchLogs by + delegateStateOf(lspApp.prefs.getBoolean(PREFS_DETAIL_PATCH_LOGS, true)) { + lspApp.prefs.edit().putBoolean(PREFS_DETAIL_PATCH_LOGS, it).apply() + } + + /** + * Whether the Shizuku shell process should start the manager again whenever it finds it gone. + * + * Off unless asked for: reviving a process someone has just force-stopped is the opposite of what they asked the + * system to do, and it is only the right answer once they have said that keeping patched apps working matters more. + */ + var keepManagerAlive by + delegateStateOf(lspApp.prefs.getBoolean(PREFS_KEEP_MANAGER_ALIVE, false)) { + lspApp.prefs.edit().putBoolean(PREFS_KEEP_MANAGER_ALIVE, it).apply() + } + + /** + * Whether the person has been shown, once, what LSPatch needs in order to stay reachable. + * + * Asked once and not again whatever they answered: the same two grants are always reachable from the Shizuku + * drawer, and a prompt that returns every launch is one people learn to dismiss without reading. + */ + var askedStayAlive by + delegateStateOf(lspApp.prefs.getBoolean(PREFS_ASKED_STAY_ALIVE, false)) { + lspApp.prefs.edit().putBoolean(PREFS_ASKED_STAY_ALIVE, it).apply() + } } diff --git a/manager/src/main/java/org/lsposed/lspatch/data/repository/LSPLogSource.kt b/manager/src/main/java/org/lsposed/lspatch/data/repository/LSPLogSource.kt index 2ed053290..9dd4062e9 100644 --- a/manager/src/main/java/org/lsposed/lspatch/data/repository/LSPLogSource.kt +++ b/manager/src/main/java/org/lsposed/lspatch/data/repository/LSPLogSource.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext -import org.lsposed.lspatch.service.LogCollectorService +import org.lsposed.lspatch.service.ManagerResidentService import org.lsposed.lspatch.share.LSPConfig import org.lsposed.lspatch.util.LSPPackageManager import org.lsposed.lspatch.util.ShizukuApi @@ -36,7 +36,7 @@ import org.matrix.vector.ui.logs.isThrowableHeader /** * LSPatch's Shizuku-backed implementation of the shared Logs screen's [LogSource]. * - * Where Vector streams a rotating file from a root daemon, LSPatch has [LogCollectorService] keep a shell-side + * Where Vector streams a rotating file from a root daemon, LSPatch has [ManagerResidentService] keep a shell-side * collector running continuously (see [ShizukuService]): it fans one live logcat into two rotating, timestamped stream * files the shell user owns — `verbose` (every line) and `framework`. This reads those parts back — so the screen's * part chevrons are real rotations, and logs captured while the screen was closed are still there — falling back to a @@ -79,7 +79,7 @@ class LSPLogSource(private val context: Context) : LogSource { // Only parts with something in them, matching what [open] reads back: a rotation opens an // empty part, and listing it would number the chevrons one ahead of the content, with the // last one selectable and blank. - ShizukuApi.listLogParts(LogCollectorService.LOG_DIR, streamPrefix(verbose)) + ShizukuApi.listLogParts(ManagerResidentService.LOG_DIR, streamPrefix(verbose)) .filter { it.second > 0L } .map { it.first } @@ -107,7 +107,7 @@ class LSPLogSource(private val context: Context) : LogSource { // The newest part, whatever is in it. A part just opened by "start a new log" is // empty, and reading the one before it instead would answer a request to start // afresh with the log the reader asked to leave behind. - val newest = ShizukuApi.listLogParts(LogCollectorService.LOG_DIR, prefix).lastOrNull()?.first + val newest = ShizukuApi.listLogParts(ManagerResidentService.LOG_DIR, prefix).lastOrNull()?.first // Only when the collector has produced no part at all (Shizuku just granted, the // service still spinning up) does a one-shot snapshot stand in for one. if (newest != null) readTail(newest) @@ -256,7 +256,7 @@ class LSPLogSource(private val context: Context) : LogSource { // none of the lines describing the breakage. if (shizuku) { for (prefix in listOf("verbose", "framework")) { - ShizukuApi.listLogParts(LogCollectorService.LOG_DIR, prefix).forEach { (path, _) -> + ShizukuApi.listLogParts(ManagerResidentService.LOG_DIR, prefix).forEach { (path, _) -> shellFileEntry("logs/${path.substringAfterLast('/')}", path) } } @@ -412,8 +412,8 @@ class LSPLogSource(private val context: Context) : LogSource { ShizukuApi.startNewLogPart() } else { ShizukuApi.startLogCollector( - LogCollectorService.LOG_DIR, - LogCollectorService.relevantUids(context), + ManagerResidentService.LOG_DIR, + ManagerResidentService.relevantUids(context), ) } } @@ -497,7 +497,7 @@ class LSPLogSource(private val context: Context) : LogSource { // refuse -- which would leave the screen blank, the one thing this exists to avoid. "logcat -d -b main -b crash -b system -v threadtime -t 4000" } else { - val uids = LogCollectorService.relevantUids(context).joinToString(",") + val uids = ManagerResidentService.relevantUids(context).joinToString(",") // No -t here, though the verbose form has one: logd applies -t itself, over every buffer, // and --uid is applied afterwards by logcat on what it was sent. The last N lines of a // chatty device can hold almost nothing from these uids, so the window would decide the diff --git a/manager/src/main/java/org/lsposed/lspatch/manager/AppBroadcastReceiver.kt b/manager/src/main/java/org/lsposed/lspatch/manager/AppBroadcastReceiver.kt index d9be08f18..64aafb61f 100644 --- a/manager/src/main/java/org/lsposed/lspatch/manager/AppBroadcastReceiver.kt +++ b/manager/src/main/java/org/lsposed/lspatch/manager/AppBroadcastReceiver.kt @@ -14,17 +14,19 @@ class AppBroadcastReceiver : BroadcastReceiver() { companion object { private const val TAG = "AppBroadcastReceiver" - private val actions = setOf( - Intent.ACTION_PACKAGE_ADDED, - Intent.ACTION_PACKAGE_REMOVED, - Intent.ACTION_PACKAGE_REPLACED - ) + private val actions = + setOf( + Intent.ACTION_PACKAGE_ADDED, + Intent.ACTION_PACKAGE_REMOVED, + Intent.ACTION_PACKAGE_REPLACED, + ) fun register(context: Context) { - val filter = IntentFilter().apply { - actions.forEach(::addAction) - addDataScheme("package") - } + val filter = + IntentFilter().apply { + actions.forEach(::addAction) + addDataScheme("package") + } context.registerReceiver(AppBroadcastReceiver(), filter) } } @@ -34,6 +36,18 @@ class AppBroadcastReceiver : BroadcastReceiver() { lspApp.globalScope.launch { Log.i(TAG, "Received intent: $intent") LSPPackageManager.fetchAppList() + // A module app that was just installed or replaced has a fresh process with no service + // in it -- the push it would normally get rides on a patched app's module query, which + // may not happen for hours. The package event is the one moment the manager knows the + // module app exists and is worth reaching, so it is handed its service here. + val changed = intent.data?.schemeSpecificPart + if ( + intent.action != Intent.ACTION_PACKAGE_REMOVED && + changed != null && + LSPPackageManager.appList.any { it.app.packageName == changed && it.isModule } + ) { + ManagerRemoteServices.pushToCompanionsAsync(listOf(changed)) + } } } } diff --git a/manager/src/main/java/org/lsposed/lspatch/manager/BootReceiver.kt b/manager/src/main/java/org/lsposed/lspatch/manager/BootReceiver.kt new file mode 100644 index 000000000..377b0ef76 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/manager/BootReceiver.kt @@ -0,0 +1,30 @@ +package org.lsposed.lspatch.manager + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import org.lsposed.lspatch.service.ManagerResidentService + +/** + * Brings the manager back up after the two events that end its process without anyone asking it to: the device + * rebooting, and the manager itself being updated. + * + * Starting a foreground service from the background is refused on Android 12 and later, with an exemption for exactly + * these broadcasts -- which is the reason this exists at all rather than the work being left to the next time someone + * opens the app. + */ +class BootReceiver : BroadcastReceiver() { + + companion object { + private const val TAG = "LSPatch-Boot" + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED && intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) { + return + } + Log.i(TAG, "Restoring the resident service after ${intent.action}") + ManagerResidentService.start(context) + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/manager/HotReloadRegistry.kt b/manager/src/main/java/org/lsposed/lspatch/manager/HotReloadRegistry.kt index d6d68b391..c458ef0de 100644 --- a/manager/src/main/java/org/lsposed/lspatch/manager/HotReloadRegistry.kt +++ b/manager/src/main/java/org/lsposed/lspatch/manager/HotReloadRegistry.kt @@ -5,14 +5,13 @@ import java.util.concurrent.ConcurrentHashMap import org.matrix.vector.ipc.IProcessChannel /** - * The live host processes the manager can reach to drive a hot reload -- its stand-in for the - * registry Vector's daemon keeps. + * The live host processes the manager can reach to drive a hot reload -- its stand-in for the registry Vector's daemon + * keeps. * * Each patched host hands the manager its [IProcessChannel] once, while it bootstraps (Vector's - * `attachProcessChannel`), and asks the manager for its modules once they load. Keyed by the calling - * `(uid, pid)`, those two facts are the whole of what a reload needs: the channel to call in on, and - * which modules that process is running. The entry is dropped when the channel dies, so a process - * that has gone is never named as a target. + * `attachProcessChannel`), and asks the manager for its modules once they load. Keyed by the calling `(uid, pid)`, + * those two facts are the whole of what a reload needs: the channel to call in on, and which modules that process is + * running. The entry is dropped when the channel dies, so a process that has gone is never named as a target. */ object HotReloadRegistry { @@ -36,6 +35,10 @@ object HotReloadRegistry { fun attach(uid: Int, pid: Int, processName: String, channel: IProcessChannel) { val id = idOf(uid, pid) + // Logged because nothing else can say it: whether a live host can still be reached is what + // decides if a reload has anywhere to go, and after a manager restart it is the one fact that + // says the hosts found their way back. + Log.i(TAG, "$processName (pid $pid) can be reached for a hot reload") val target = Target(id, uid, pid, processName, channel) targets[id] = target runCatching { channel.asBinder().linkToDeath({ targets.remove(id) }, 0) } @@ -50,8 +53,7 @@ object HotReloadRegistry { targets[idOf(uid, pid)]?.modules = modules } - fun targetsFor(modulePackageName: String): List = - targets.values.filter { modulePackageName in it.modules } + fun targetsFor(modulePackageName: String): List = targets.values.filter { modulePackageName in it.modules } fun target(id: Long): Target? = targets[id] } diff --git a/manager/src/main/java/org/lsposed/lspatch/manager/ModuleDeliveryReports.kt b/manager/src/main/java/org/lsposed/lspatch/manager/ModuleDeliveryReports.kt new file mode 100644 index 000000000..d7ab70c0f --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/manager/ModuleDeliveryReports.kt @@ -0,0 +1,70 @@ +package org.lsposed.lspatch.manager + +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.lsposed.lspatch.lspApp + +/** + * The launches a patched app made without the manager answering. + * + * A patched app that cannot reach the manager loads its modules from its own snapshot and carries on, which is the + * right thing to do and also invisible: the one party who could report the miss is the one that was not running. So the + * app counts its misses and hands the count over on the next bind that does reach here, and this is where they are kept + * until someone reads them. + * + * What they mean is worth stating plainly, because the number alone reads as a defect: a device that reaps or + * force-stops background apps is doing what it was configured to do, and these counts are the evidence of it — the + * reason to whitelist the manager, not a sign that patching went wrong. + */ +object ModuleDeliveryReports { + + private const val TAG = "LSPatch-Delivery" + private const val KEY = "module_delivery_fallbacks" + + data class Report(val packageName: String, val fallbacks: Int, val lastFallbackAt: Long) + + private val _reports = MutableStateFlow(load()) + val reports: StateFlow> = _reports.asStateFlow() + + /** The count an app reported, replacing what it reported before -- the app counts, this only keeps. */ + fun record(packageName: String, fallbacks: Int, lastFallbackAt: Long) { + if (fallbacks <= 0) return + Log.i(TAG, "$packageName started $fallbacks time(s) without reaching the manager") + val merged = + _reports.value.filter { it.packageName != packageName } + Report(packageName, fallbacks, lastFallbackAt) + _reports.value = merged.sortedByDescending { it.lastFallbackAt } + save() + } + + fun clear() { + _reports.value = emptyList() + save() + } + + private fun save() { + runCatching { + lspApp.prefs + .edit() + .putStringSet( + KEY, + _reports.value.map { "${it.packageName}|${it.fallbacks}|${it.lastFallbackAt}" }.toSet(), + ) + .apply() + } + } + + private fun load(): List = runCatching { + lspApp.prefs + .getStringSet(KEY, emptySet()) + .orEmpty() + .mapNotNull { line -> + val parts = line.split('|') + if (parts.size != 3) return@mapNotNull null + Report(parts[0], parts[1].toIntOrNull() ?: return@mapNotNull null, parts[2].toLongOrNull() ?: 0L) + } + .sortedByDescending { it.lastFallbackAt } + } + .getOrDefault(emptyList()) +} diff --git a/manager/src/main/java/org/lsposed/lspatch/manager/ModuleService.kt b/manager/src/main/java/org/lsposed/lspatch/manager/ModuleService.kt index aa853b790..7ed181132 100644 --- a/manager/src/main/java/org/lsposed/lspatch/manager/ModuleService.kt +++ b/manager/src/main/java/org/lsposed/lspatch/manager/ModuleService.kt @@ -6,21 +6,54 @@ import android.os.IBinder import android.util.Log import kotlinx.coroutines.launch import org.lsposed.lspatch.lspApp +import org.lsposed.lspatch.util.LSPPackageManager class ModuleService : Service() { companion object { private const val TAG = "ModuleService" + + /** What a host reports about launches it had to make without this manager; see [ModuleDeliveryReports]. */ + private const val EXTRA_FALLBACKS = "fallbackLaunches" + private const val EXTRA_LAST_FALLBACK_AT = "lastFallbackAt" } override fun onBind(intent: Intent): IBinder? { val packageName = intent.getStringExtra("packageName") ?: return null - // TODO: Authentication + // Who the caller is cannot be established here: onBind does not run inside the binder + // transaction that triggered it, so Binder.getCallingUid() answers with this manager's own uid + // rather than the app's. It is established where it can be -- every call on ManagerService + // resolves the calling uid while a transaction is live, and serves only that uid's modules -- + // so this hands out a binder that is harmless until the caller identifies itself by making a + // call on it. Log.i(TAG, "$packageName requests binder") + recordDelivery(packageName, intent) // After the binder, never before it: this bind may be what created the process, and the app // on the other end is holding its own startup open until this call returns. The rest of the // manager's start-up work is posted so it lands once that app is on its way. lspApp.globalScope.launch { lspApp.startBackgroundWork() } return ManagerService.asBinder() } + + /** + * Takes a host's count of the launches it had to make without this manager. + * + * The package name is the caller's own claim, for the reason above, so it is kept only when it names an app this + * device has actually patched. That is as far as it can be checked, and it is enough for what the count is: a + * number shown to the person who owns the device, about their own apps, that changes nothing but what they are + * told. + */ + private fun recordDelivery(packageName: String, intent: Intent) { + val fallbacks = intent.getIntExtra(EXTRA_FALLBACKS, 0) + if (fallbacks <= 0) return + val patched = + LSPPackageManager.appList.any { + it.app.packageName == packageName && it.app.metaData?.containsKey("lspatch") == true + } + if (!patched) { + Log.w(TAG, "Ignoring a delivery report from $packageName, which is not a patched app here") + return + } + ModuleDeliveryReports.record(packageName, fallbacks, intent.getLongExtra(EXTRA_LAST_FALLBACK_AT, 0L)) + } } diff --git a/manager/src/main/java/org/lsposed/lspatch/service/LogCollectorService.kt b/manager/src/main/java/org/lsposed/lspatch/service/LogCollectorService.kt deleted file mode 100644 index 8637a4bbb..000000000 --- a/manager/src/main/java/org/lsposed/lspatch/service/LogCollectorService.kt +++ /dev/null @@ -1,185 +0,0 @@ -package org.lsposed.lspatch.service - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationCompat -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import org.lsposed.lspatch.R -import org.lsposed.lspatch.util.LSPPackageManager -import org.lsposed.lspatch.util.ShizukuApi - -/** - * Keeps LSPatch collecting logs whenever it is alive. - * - * Two jobs in one service. The ongoing notification is what keeps the app's process from being reaped in the background - * — collection is only continuous if the process that holds the Shizuku binding stays up — and a supervisor loop starts - * the shell-side `logcat -f` collector once Shizuku is granted and restarts it if it ever dies (the buffer was cleared, - * the logcat was killed). The collector itself runs as the shell user and rotates its own files; see [ShizukuService]. - * - * The service is deliberately cheap: once the collector is healthy the loop is a binder round trip every few seconds - * that does nothing, and the notification sits at minimum importance with no badge, so it is present in the shade but - * never intrudes. - */ -class LogCollectorService : Service() { - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - /** - * The supervisor loop, started once however often the service is asked to start. - * - * onStartCommand runs again on every start request and on the system's own restart of a sticky service, and each - * run used to add another loop to the same scope: several supervisors then raced to start a collector, each tearing - * down what it took to be the running one, leaving orphaned `logcat` children nobody drained. - */ - private var supervisor: Job? = null - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onCreate() { - super.onCreate() - createChannel() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - startAsForeground() - if (supervisor?.isActive == true) return START_STICKY - supervisor = scope.launch { - while (isActive) { - // refresh() rather than ensureReady(): this tick repeats forever, and a device - // without Shizuku would otherwise report the same thing to the reader on a loop. - // The Logs screen already explains an absent Shizuku in place. - if (ShizukuApi.refresh()) { - // The uid set is what the framework stream is routed by, and it is only right - // once every installed package has been scanned. That scan is started elsewhere - // and takes seconds; a collector started before it finished knew the manager's - // uid alone and routed nothing else for as long as it stayed alive, which is - // what left a patched app's and its modules' lines out of the stream entirely. - // Guarded, because this is the one call in the tick that is not: a package - // scan can throw (a package uninstalled mid-scan, a device with enough apps to - // burst a binder transaction), and an exception here leaves the loop's scope - // with no handler -- taking the process down, and with it collection for good, - // since the supervisor is only ever started again by a fresh start command. - runCatching { LSPPackageManager.ensureAppList() } - val uids = relevantUids(this@LogCollectorService) - if (ShizukuApi.isLogCollectorRunning()) { - // An app patched, or a module installed, since the collector started has a - // uid it has never seen. Pushing the current set on every tick is what lets - // it join the stream in place, without a restart and without a gap. - ShizukuApi.updateLogCollectorUids(uids) - } else { - ShizukuApi.startLogCollector(LOG_DIR, uids) - } - } - delay(CHECK_INTERVAL_MS) - } - } - // Restarted by the system if it is killed, so collection resumes without the user reopening - // the app. The collector is re-established by the loop above on the next tick. - return START_STICKY - } - - override fun onDestroy() { - scope.cancel() - // Turning monitoring off should not leave a logcat pinned to the buffer. Best effort on a - // detached scope, since this one is already cancelled. - CoroutineScope(Dispatchers.IO).launch { - ShizukuApi.stopLogCollector() - // And hand the shell process back: nothing else in the app needs it while monitoring is - // off, and an unbound one would sit there until the device reboots. - ShizukuApi.releaseUserService() - } - super.onDestroy() - } - - private fun startAsForeground() { - val notification = buildNotification() - // API 34 requires a declared foreground-service type at the call site; log collection maps - // to none of the standard buckets, so it is "special use" (declared in the manifest too). - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) - } else { - startForeground(NOTIF_ID, notification) - } - } - - private fun buildNotification(): Notification = - NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.log_service_title)) - .setContentText(getString(R.string.log_service_text)) - .setSmallIcon(R.mipmap.ic_launcher) - .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setCategory(Notification.CATEGORY_SERVICE) - .setShowWhen(false) - .build() - - private fun createChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = - NotificationChannel( - CHANNEL_ID, - getString(R.string.log_service_channel), - NotificationManager.IMPORTANCE_MIN, - ) - channel.setShowBadge(false) - getSystemService(NotificationManager::class.java).createNotificationChannel(channel) - } - } - - companion object { - /** Shell-owned; the app reads parts back through Shizuku, never directly (cross-UID). */ - const val LOG_DIR = "/data/local/tmp/lspatch-logs" - - /** - * The uids whose lines belong in the framework stream: the manager itself, and every patched app and module. - * Each is read straight off its [android.content.pm.ApplicationInfo], so no extra PackageManager round trip is - * needed; the collector matches lines by these and by nothing else. - * - * Empty of everything but the manager until the package scan has run, which is why the caller waits for it. - */ - fun relevantUids(context: Context): IntArray { - val own = context.applicationInfo.uid - val others = LinkedHashSet() - LSPPackageManager.appList - .filter { it.isModule || it.app.metaData?.containsKey("lspatch") == true } - .forEach { if (it.app.uid != own) others.add(it.app.uid) } - return intArrayOf(own) + others.toIntArray() - } - - private const val CHANNEL_ID = "lspatch_log_monitor" - private const val NOTIF_ID = 0x15 - private const val CHECK_INTERVAL_MS = 15_000L - - fun start(context: Context) { - val intent = Intent(context, LogCollectorService::class.java) - // Background-start restrictions (Android 12+) throw when there is no foreground reason to - // start; the caller starts this from a user-visible launch, and the guard keeps a stray - // background start from taking the app down. - runCatching { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } - } - } - - fun stop(context: Context) { - runCatching { context.stopService(Intent(context, LogCollectorService::class.java)) } - } - } -} diff --git a/manager/src/main/java/org/lsposed/lspatch/service/ManagerResidentService.kt b/manager/src/main/java/org/lsposed/lspatch/service/ManagerResidentService.kt new file mode 100644 index 000000000..c4824187b --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/service/ManagerResidentService.kt @@ -0,0 +1,254 @@ +package org.lsposed.lspatch.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.lsposed.lspatch.R +import org.lsposed.lspatch.config.Configs +import org.lsposed.lspatch.util.LSPPackageManager +import org.lsposed.lspatch.util.ShizukuApi + +/** + * Keeps the manager present for as long as anything on this device depends on it. + * + * A patched app reaches the manager the moment it starts, and a module app is handed its service by the manager pushing + * one; both are answered late, or not at all, if the manager's process has been reaped in the meantime. A foreground + * service does not make the process unkillable — nothing an ordinary app can do makes it unkillable, and a force-stop + * ends it whatever it is doing — but it does move the process out of the bucket ordinary memory pressure empties first, + * which covers the common case at the cost of one silent notification. + * + * Two jobs ride on that presence. Log collection, which needs the process alive anyway because the Shizuku binding + * lives in it, is supervised here as before: the shell-side `logcat -f` collector is started once Shizuku is granted + * and restarted if it dies. And, when the person has asked for it, the shell-side watchdog is armed — see + * [ShizukuApi.startManagerWatchdog], which is the only part of this that survives the manager's own death. + * + * The service is deliberately cheap: once everything is healthy the loop is a binder round trip every few seconds that + * does nothing, and the notification sits at minimum importance with no badge. + */ +class ManagerResidentService : Service() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** + * The supervisor loop, started once however often the service is asked to start. + * + * onStartCommand runs again on every start request and on the system's own restart of a sticky service, and each + * run used to add another loop to the same scope: several supervisors then raced to start a collector, each tearing + * down what it took to be the running one, leaving orphaned `logcat` children nobody drained. + */ + private var supervisor: Job? = null + + private var collecting = false + + /** + * Whether the watchdog's state has been stated once since this process started. + * + * The shell process outlives the manager, so a watchdog armed by a previous life of this app may still be running + * with nobody here knowing it; the first tick therefore says what it wants either way, and later ticks only speak + * when there is something to say. + */ + private var watchdogReconciled = false + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startAsForeground() + if (supervisor?.isActive == true) return START_STICKY + supervisor = scope.launch { + while (isActive) { + // Both of the answers below are read off the app list, and neither is right until + // every installed package has been scanned. Guarded, because this is the one call in + // the tick that is not: a package scan can throw (a package uninstalled mid-scan, a + // device with enough apps to burst a binder transaction), and an exception here + // leaves the loop's scope with no handler -- taking the process down, and with it + // everything this service is here to keep, since the supervisor is only ever started + // again by a fresh start command. + runCatching { LSPPackageManager.ensureAppList() } + if (nothingToServe()) { + // A fresh install with nothing patched has no one to be reachable for, and an + // ongoing notification for that would be a claim about work that is not happening. + Log.i(TAG, "Nothing patched on this device; standing down") + stopSelf() + return@launch + } + // refresh() rather than ensureReady(): this tick repeats forever, and a device + // without Shizuku would otherwise report the same thing to the reader on a loop. + // The Logs screen already explains an absent Shizuku in place. + val shizuku = ShizukuApi.refresh() + if (shizuku) { + val uids = relevantUids(this@ManagerResidentService) + if (ShizukuApi.isLogCollectorRunning()) { + // An app patched, or a module installed, since the collector started has a + // uid it has never seen. Pushing the current set on every tick is what lets + // it join the stream in place, without a restart and without a gap. + ShizukuApi.updateLogCollectorUids(uids) + } else { + ShizukuApi.startLogCollector(LOG_DIR, uids) + } + armWatchdog() + } + val nowCollecting = shizuku && ShizukuApi.isLogCollectorRunning() + if (nowCollecting != collecting) { + collecting = nowCollecting + // The notification says what the service is actually doing; collecting or merely + // present are different claims and only one of them is true at a time. + startAsForeground() + } + delay(CHECK_INTERVAL_MS) + } + } + // Restarted by the system if it is killed, so presence resumes without the user reopening the + // app. Everything above is re-established by the loop on the next tick. + return START_STICKY + } + + /** + * Whether anything on this device depends on the manager being reachable. + * + * An empty app list means it has not been read yet, which is not the same as an empty answer; the next tick asks + * again rather than this one guessing. + */ + private fun nothingToServe(): Boolean { + val apps = LSPPackageManager.appList + if (apps.isEmpty()) return false + return apps.none { it.isModule || it.app.metaData?.containsKey("lspatch") == true } + } + + /** + * Hands the keep-alive over to the shell process, or takes it back. + * + * Re-stated on every tick rather than once: the Shizuku user service is a separate process with its own lifetime, + * so it may have been started after the last time this was said, and it is the one that has to be told again. + */ + private suspend fun armWatchdog() { + if (Configs.keepManagerAlive) { + ShizukuApi.startManagerWatchdog(packageName, WATCHDOG_COMPONENT, WATCHDOG_INTERVAL_S) + } else if (!watchdogReconciled || ShizukuApi.shellIsDaemon) { + ShizukuApi.stopManagerWatchdog() + } + watchdogReconciled = true + } + + override fun onDestroy() { + scope.cancel() + // Turning monitoring off should not leave a logcat pinned to the buffer. Best effort on a + // detached scope, since this one is already cancelled. + CoroutineScope(Dispatchers.IO).launch { + ShizukuApi.stopLogCollector() + // And hand the shell process back: nothing else in the app needs it while monitoring is + // off, and an unbound one would sit there until the device reboots. The watchdog, if it is + // armed, is deliberately left running -- it is the one thing whose job starts when this + // process ends. + if (!Configs.keepManagerAlive) ShizukuApi.releaseUserService() + } + super.onDestroy() + } + + private fun startAsForeground() { + val notification = buildNotification() + // API 34 requires a declared foreground-service type at the call site; neither log collection + // nor being reachable maps to a standard bucket, so it is "special use" (declared in the + // manifest too). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + startForeground(NOTIF_ID, notification) + } + } + + private fun buildNotification(): Notification = + NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(if (collecting) R.string.log_service_title else R.string.resident_service_title)) + .setContentText(getString(if (collecting) R.string.log_service_text else R.string.resident_service_text)) + .setSmallIcon(R.mipmap.ic_launcher) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(Notification.CATEGORY_SERVICE) + .setShowWhen(false) + .build() + + private fun createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = + NotificationChannel( + CHANNEL_ID, + getString(R.string.log_service_channel), + NotificationManager.IMPORTANCE_MIN, + ) + channel.setShowBadge(false) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + + companion object { + /** Shell-owned; the app reads parts back through Shizuku, never directly (cross-UID). */ + const val LOG_DIR = "/data/local/tmp/lspatch-logs" + + /** What the shell-side watchdog starts to bring the manager back -- this service itself. */ + private const val WATCHDOG_COMPONENT = "org.lsposed.lspatch/.service.ManagerResidentService" + + private const val WATCHDOG_INTERVAL_S = 120 + + /** + * The uids whose lines belong in the framework stream: the manager itself, and every patched app and module. + * Each is read straight off its [android.content.pm.ApplicationInfo], so no extra PackageManager round trip is + * needed; the collector matches lines by these and by nothing else. + * + * Empty of everything but the manager until the package scan has run, which is why the caller waits for it. + */ + fun relevantUids(context: Context): IntArray { + val own = context.applicationInfo.uid + val others = LinkedHashSet() + LSPPackageManager.appList + .filter { it.isModule || it.app.metaData?.containsKey("lspatch") == true } + .forEach { if (it.app.uid != own) others.add(it.app.uid) } + return intArrayOf(own) + others.toIntArray() + } + + private const val TAG = "LSPatch-Resident" + + private const val CHANNEL_ID = "lspatch_log_monitor" + private const val NOTIF_ID = 0x15 + private const val CHECK_INTERVAL_MS = 15_000L + + fun start(context: Context) { + val intent = Intent(context, ManagerResidentService::class.java) + // Background-start restrictions (Android 12+) throw when there is no foreground reason to + // start; the caller starts this from a user-visible launch or from a boot broadcast, both + // of which are allowed, and the guard keeps a stray background start from taking the app + // down. + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + } + + fun stop(context: Context) { + runCatching { context.stopService(Intent(context, ManagerResidentService::class.java)) } + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/ShizukuSheet.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/ShizukuSheet.kt new file mode 100644 index 000000000..9441a22f5 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/ShizukuSheet.kt @@ -0,0 +1,215 @@ +package org.lsposed.lspatch.ui.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material.icons.rounded.BatteryAlert +import androidx.compose.material.icons.rounded.Restore +import androidx.compose.material.icons.rounded.Terminal +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.lsposed.lspatch.R +import org.lsposed.lspatch.config.Configs +import org.lsposed.lspatch.manager.ModuleDeliveryReports +import org.lsposed.lspatch.util.LSPPackageManager +import org.lsposed.lspatch.util.ShizukuApi +import org.matrix.vector.ui.ActionDrawerHeader +import org.matrix.vector.ui.ActionDrawerItem +import org.matrix.vector.ui.LocalDialogLocalizer + +/** Shizuku's own package: what the header names, and what the last row opens. */ +private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api" + +/** + * Everything LSPatch does with Shizuku, in the drawer the System card's Shizuku row opens. + * + * That row used to launch the Shizuku app, which answered the wrong question: what its reader wants is what LSPatch is + * doing with the shell, not Shizuku's own screen. Opening the app is still here -- as one row among the others, where + * it reads as one choice rather than as the only one. + * + * A drawer rather than a page because every one of these is a single act on a single subject, which is exactly what a + * package's action drawer already is; the same header, the same rows, and no screen to navigate away from. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShizukuSheet(onDismiss: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val reports by ModuleDeliveryReports.reports.collectAsStateWithLifecycle() + + val granted = ShizukuApi.isPermissionGranted + val version = if (granted) ShizukuApi.serverVersion() else null + + var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) } + val askExemption = rememberBatteryExemptionRequest { exempt = it } + var outcomes by remember { mutableStateOf?>(null) } + var asking by remember { mutableStateOf(false) } + + val openShizuku = remember { LSPPackageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) { + // A sheet is its own window, and a new window gets a fresh set of Android composition locals + // taken from that window's context -- which drops the in-app language override on the way in. + LocalDialogLocalizer.current { + Row(verticalAlignment = Alignment.CenterVertically) { + ActionDrawerHeader( + label = "Shizuku", + packageName = SHIZUKU_PACKAGE, + modifier = Modifier.weight(1f), + icon = { Icon(Icons.Rounded.Terminal, contentDescription = null) }, + extraContent = { + Text( + text = + if (version != null) stringResource(R.string.shizuku_available) + " · API $version" + else stringResource(R.string.shizuku_unavailable), + style = MaterialTheme.typography.bodySmall, + color = + if (granted) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.error, + ) + }, + ) + // Beside the name it belongs to rather than among the rows below: everything in that + // list is something LSPatch does with the shell, and this one leaves for another app. + if (openShizuku != null) { + IconButton( + onClick = { + onDismiss() + runCatching { context.startActivity(openShizuku) } + }, + modifier = Modifier.padding(end = 12.dp), + ) { + Icon( + Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = stringResource(R.string.shizuku_open_app), + ) + } + } + } + HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) + + if (!granted) { + ActionDrawerItem( + icon = Icons.Rounded.Warning, + title = stringResource(R.string.shizuku_failure_grant), + subtitle = stringResource(R.string.shizuku_failure_hint_not_granted), + tint = MaterialTheme.colorScheme.error, + ) { + ShizukuApi.requestPermission() + } + } + + // A switch, so it is announced as one: the row carries state as well as an action, and + // ActionDrawerItem leaves the behaviour to the caller for exactly this case. + val keepAlive = Configs.keepManagerAlive && granted + ActionDrawerItem( + icon = Icons.Rounded.Restore, + title = stringResource(R.string.background_keep_alive), + subtitle = + if (granted) stringResource(R.string.background_keep_alive_summary) + else stringResource(R.string.background_keep_alive_needs_shizuku), + modifier = + Modifier.toggleable( + value = keepAlive, + enabled = granted, + role = Role.Switch, + // The watchdog runs inside the shell process; with no Shizuku, nothing to arm. + onValueChange = { wanted -> Configs.keepManagerAlive = wanted }, + ), + trailing = { Switch(checked = keepAlive, onCheckedChange = null, enabled = granted) }, + ) + + if (granted) { + // Only what this device knows about and said no to. A limit it has never heard of is + // not one it withheld, and naming it here would send the reader looking for a setting + // their phone does not have. + val refused = outcomes?.filter { it.verdict == ShizukuApi.ShellVerdict.Refused } + ActionDrawerItem( + icon = Icons.Rounded.Terminal, + title = stringResource(R.string.background_whitelist), + subtitle = + when { + asking -> stringResource(R.string.background_whitelist_running) + // Named rather than counted: which limit this device would not lift is the + // whole of what the reader learns from having asked. + refused?.isEmpty() == true -> stringResource(R.string.background_whitelist_done) + refused != null -> + stringResource( + R.string.background_whitelist_refused, + refused.joinToString(", ") { it.label }, + ) + else -> stringResource(R.string.background_whitelist_summary) + }, + tint = if (refused?.isNotEmpty() == true) MaterialTheme.colorScheme.error else null, + ) { + if (!asking) { + asking = true + scope.launch { + outcomes = ShizukuApi.exemptFromBackgroundLimits(context.packageName) + exempt = isIgnoringBatteryOptimizations(context) + asking = false + } + } + } + } + + ActionDrawerItem( + icon = Icons.Rounded.BatteryAlert, + title = stringResource(R.string.background_battery), + subtitle = + if (exempt) stringResource(R.string.background_battery_done) + else stringResource(R.string.background_battery_summary), + // A statement once it is granted: there is nothing left to ask for. + onClick = if (exempt) null else askExemption, + ) + + if (reports.isNotEmpty()) { + ActionDrawerItem( + icon = Icons.Rounded.Warning, + title = stringResource(R.string.background_reports_title), + // Resolved in composition, one line per report, and only then joined: a string read + // from a context at the moment it is needed is read outside composition, where a + // configuration change since the screen was drawn has not been seen. `map` is + // inline, so each line is still resolved where composition can reach it. + subtitle = + reports + .map { + stringResource( + R.string.background_reports_line, + it.packageName, + it.fallbacks, + ) + } + .joinToString("\n"), + tint = MaterialTheme.colorScheme.error, + ) { + ModuleDeliveryReports.clear() + } + } + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/StayAliveSheet.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/StayAliveSheet.kt new file mode 100644 index 000000000..6ab666a11 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/StayAliveSheet.kt @@ -0,0 +1,149 @@ +package org.lsposed.lspatch.ui.component + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BatteryAlert +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.lsposed.lspatch.R +import org.lsposed.lspatch.lspApp +import org.matrix.vector.ui.ActionDrawerHeader +import org.matrix.vector.ui.ActionDrawerItem +import org.matrix.vector.ui.LocalDialogLocalizer + +/** + * What LSPatch needs in order to still be there when a patched app starts, asked once and explained before it is asked. + * + * Both grants are the platform's own dialogs, and both are easy to refuse when they arrive out of nowhere — one asks + * about notifications for an app the person has not seen post one, the other is worded by the system as though the app + * were misbehaving. Saying first what they are for is the difference between a considered yes and a reflexive no. + * + * Neither is required. Refusing costs the ongoing notification (the service still runs, just invisibly) and leaves the + * manager subject to doze; both remain reachable afterwards from the Shizuku drawer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StayAliveSheet(onDismiss: () -> Unit) { + val context = LocalContext.current + + var notifications by remember { mutableStateOf(hasNotificationPermission(context)) } + var exempt by remember { mutableStateOf(isIgnoringBatteryOptimizations(context)) } + + val askNotifications = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + notifications = granted + } + + val askExemption = rememberBatteryExemptionRequest { exempt = it } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) { + LocalDialogLocalizer.current { + ActionDrawerHeader( + label = stringResource(R.string.stay_alive_title), + packageName = lspApp.packageName, + icon = { Icon(Icons.Rounded.BatteryAlert, contentDescription = null) }, + extraContent = { + Text( + text = stringResource(R.string.stay_alive_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 4.dp)) + + ActionDrawerItem( + icon = if (notifications) Icons.Rounded.Check else Icons.Rounded.Notifications, + title = stringResource(R.string.stay_alive_notifications), + subtitle = stringResource(R.string.stay_alive_notifications_summary), + onClick = + if (notifications) null + else { + { runCatching { askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) } } + }, + ) + + ActionDrawerItem( + icon = if (exempt) Icons.Rounded.Check else Icons.Rounded.BatteryAlert, + title = stringResource(R.string.background_battery), + subtitle = stringResource(R.string.background_battery_summary), + onClick = if (exempt) null else askExemption, + ) + + ActionDrawerItem( + icon = Icons.Rounded.Check, + title = stringResource(R.string.stay_alive_done), + onClick = onDismiss, + ) + } + } +} + +/** + * Whether this build even has a notification permission to ask for. + * + * Below Android 13 there is none, and a row offering to grant it would be offering nothing. + */ +fun hasNotificationPermission(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + +fun isIgnoringBatteryOptimizations(context: Context): Boolean = runCatching { + context.getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(context.packageName) +} + .getOrDefault(false) + +/** + * Opens the platform's own exemption dialog, and says afterwards whether the app is exempt. + * + * Asked for a result rather than merely started, because the answer is the person's and arrives long after the ask: + * reading the state back in the same breath as the request reads it from before the dialog was even drawn, and the row + * goes on saying the app is not exempt after they have said it may be. The dialog reports a cancelled result whichever + * button was pressed, so what it left behind is read from the platform rather than taken from the result. + * + * The targeted action asks for this one app and is the only form that leads anywhere on most builds; a device that has + * removed it falls back to the settings list, where the person finds LSPatch themselves. Neither is given + * FLAG_ACTIVITY_NEW_TASK: an activity started into its own task reports its result immediately and to nobody. + */ +@Composable +fun rememberBatteryExemptionRequest(onAnswered: (Boolean) -> Unit): () -> Unit { + val context = LocalContext.current + val launcher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + onAnswered(isIgnoringBatteryOptimizations(context)) + } + return { + val direct = + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + .setData(Uri.parse("package:${context.packageName}")) + if (runCatching { launcher.launch(direct) }.isFailure) { + runCatching { launcher.launch(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) } + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/HomeScreen.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/HomeScreen.kt index f68f27357..8005f2199 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/HomeScreen.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/HomeScreen.kt @@ -82,16 +82,22 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.lsposed.lspatch.R +import org.lsposed.lspatch.config.Configs import org.lsposed.lspatch.data.model.PatchMode import org.lsposed.lspatch.data.model.PatchOrigin import org.lsposed.lspatch.data.model.PatchRequest import org.lsposed.lspatch.data.model.PatchTarget import org.lsposed.lspatch.data.repository.PatchRequestStore import org.lsposed.lspatch.lspApp +import org.lsposed.lspatch.manager.ModuleDeliveryReports import org.lsposed.lspatch.share.Constants import org.lsposed.lspatch.share.LSPConfig import org.lsposed.lspatch.ui.appearance.LSPAmbienceSettings import org.lsposed.lspatch.ui.appearance.LSPSettings +import org.lsposed.lspatch.ui.component.ShizukuSheet +import org.lsposed.lspatch.ui.component.StayAliveSheet +import org.lsposed.lspatch.ui.component.hasNotificationPermission +import org.lsposed.lspatch.ui.component.isIgnoringBatteryOptimizations import org.lsposed.lspatch.ui.page.destinations.ManageScreenDestination import org.lsposed.lspatch.ui.page.destinations.NewPatchScreenDestination import org.lsposed.lspatch.ui.page.destinations.UpdateScreenDestination @@ -170,6 +176,15 @@ fun HomeScreen(navigator: DestinationsNavigator) { } } + // Once, on the first open, and only while there is something left to ask for: what LSPatch needs + // to still be there when a patched app starts. Deciding it here rather than inside the sheet keeps + // a sheet from opening and closing itself on a device that already granted both. + var showStayAlive by remember { + mutableStateOf( + !Configs.askedStayAlive && !(hasNotificationPermission(context) && isIgnoringBatteryOptimizations(context)) + ) + } + var showAppearance by remember { mutableStateOf(false) } var showLanguage by remember { mutableStateOf(false) } val ambienceKey by LSPSettings.headerAmbience.collectAsStateWithLifecycle() @@ -259,6 +274,14 @@ fun HomeScreen(navigator: DestinationsNavigator) { } } + if (showStayAlive) { + StayAliveSheet( + onDismiss = { + showStayAlive = false + Configs.askedStayAlive = true + } + ) + } if (showAppearance) { val floatingNav by LSPSettings.floatingNav.collectAsStateWithLifecycle() AppearanceSheet( @@ -385,11 +408,10 @@ private fun SystemPropertiesCard(navigator: DestinationsNavigator) { // getVersion is only valid while the binder is alive, which a granted permission guarantees. val shizukuVersion = if (shizukuGranted) ShizukuApi.serverVersion() else null val shizukuValue = if (shizukuVersion != null) "API $shizukuVersion" else stringResource(R.string.shizuku_off) - // Tapping Shizuku opens the Shizuku app itself; inert when it is not installed. - val openShizuku: (() -> Unit)? = - LSPPackageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE)?.let { intent -> - { runCatching { context.startActivity(intent) } } - } + // Tapping Shizuku opens what LSPatch does with it, not Shizuku's own screen: this row's reader is + // asking about the shell LSPatch runs on, and opening the app is one of the rows in that drawer. + var showShizukuSheet by remember { mutableStateOf(false) } + val openShizuku: () -> Unit = { showShizukuSheet = true } val toApplications = { navigator.navigate(ManageScreenDestination(initialTab = 0)) @@ -417,11 +439,15 @@ private fun SystemPropertiesCard(navigator: DestinationsNavigator) { Icons.Rounded.Badge, stringResource(R.string.home_package), lspApp.packageName, - ) { - showPackageDialog = true - } + onClick = { showPackageDialog = true }, + ) - val shizukuProp = SystemProperty(Icons.Rounded.Terminal, "Shizuku", shizukuValue, openShizuku) + // Marked, not spelled out: a patched app that had to start without the manager is a fact about the + // shell LSPatch runs on, so it belongs on this row rather than in a card of its own -- and the row + // it belongs to already leads to the page that explains it. + val missedLaunches = ModuleDeliveryReports.reports.collectAsStateWithLifecycle().value.isNotEmpty() + val shizukuProp = + SystemProperty(Icons.Rounded.Terminal, "Shizuku", shizukuValue, openShizuku, mark = missedLaunches) val androidProp = SystemProperty(Icons.Rounded.Android, "Android", androidAndAbi) val deviceProp = SystemProperty(Icons.Rounded.Smartphone, stringResource(R.string.home_device), deviceName) // The core *is* Vector; its row carries the tag it was built from and links to that exact commit, @@ -517,6 +543,9 @@ private fun SystemPropertiesCard(navigator: DestinationsNavigator) { if (showPackageDialog) { ManagerPackageDialog(onDismiss = { showPackageDialog = false }) } + if (showShizukuSheet) { + ShizukuSheet(onDismiss = { showShizukuSheet = false }) + } } /** @@ -646,6 +675,8 @@ private data class SystemProperty( val label: String, val value: String, val onClick: (() -> Unit)? = null, + /** Draws a dot beside the value: something behind this row wants the reader, without saying so twice. */ + val mark: Boolean = false, ) /** A full-width property row: icon well, label, value, and a chevron when it leads somewhere. */ @@ -688,6 +719,13 @@ private fun PropertyRow(property: SystemProperty) { color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.End, ) + if (property.mark) { + Spacer(Modifier.width(6.dp)) + Box( + modifier = + Modifier.size(6.dp).clip(RoundedCornerShape(3.dp)).background(MaterialTheme.colorScheme.error) + ) + } if (clickable) { Spacer(Modifier.width(4.dp)) Icon( diff --git a/manager/src/main/java/org/lsposed/lspatch/util/ShizukuApi.kt b/manager/src/main/java/org/lsposed/lspatch/util/ShizukuApi.kt index 8dd10b09a..ce833b4e5 100644 --- a/manager/src/main/java/org/lsposed/lspatch/util/ShizukuApi.kt +++ b/manager/src/main/java/org/lsposed/lspatch/util/ShizukuApi.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import moe.shizuku.server.IShizukuService as IShizukuServer import org.lsposed.lspatch.IShizukuService +import org.lsposed.lspatch.R import org.lsposed.lspatch.ShizukuService import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper @@ -398,18 +399,39 @@ object ShizukuApi { guard(op, fallback) { block(service) } } - // One instance, because unbinding takes the same args the bind was given: a copy built on the - // spot is fine to bind with and useless to let go with. - private val serviceArgs by lazy { + /** + * Whether the shell process should outlive this app. + * + * Normally it should not -- a shell-uid process left running for the rest of the boot is a leak, and + * [releaseUserService] exists to avoid exactly that. The exception is the watchdog: its whole job starts when the + * manager's process ends, so while it is armed the shell service is asked for as a daemon instead. The two + * lifetimes are two different service records, which is why the args come in pairs and why switching means letting + * the running one go first. + */ + @Volatile private var daemonRequested = false + + val shellIsDaemon + get() = daemonRequested + + /** The args a running service was bound with -- unbinding takes the same ones the bind was given. */ + @Volatile private var boundArgs: Shizuku.UserServiceArgs? = null + + private val transientArgs by lazy { buildServiceArgs(daemon = false) } + private val daemonArgs by lazy { buildServiceArgs(daemon = true) } + + private val serviceArgs + get() = if (daemonRequested) daemonArgs else transientArgs + + private fun buildServiceArgs(daemon: Boolean) = Shizuku.UserServiceArgs(ComponentName(appContext.packageName, ShizukuService::class.java.name)) - .daemon(false) + .daemon(daemon) + .tag(if (daemon) "lspatch-shell-daemon" else "lspatch-shell") .processNameSuffix("service") .debuggable(true) // Version the service by the app's version code: on an upgrade Shizuku tears down the old // instance and starts a fresh one, so a rebuilt ShizukuService (new AIDL, new collector) // actually takes effect instead of the app binding to a stale cached process. .version(org.lsposed.lspatch.share.LSPConfig.instance.VERSION_CODE) - } private fun bindUserService() { if (userService != null) return @@ -419,7 +441,9 @@ object ShizukuApi { binding = true bindingSince = SystemClock.elapsedRealtime() try { - Shizuku.bindUserService(serviceArgs, userServiceConnection) + val args = serviceArgs + boundArgs = args + Shizuku.bindUserService(args, userServiceConnection) } catch (t: Throwable) { binding = false record(ShizukuOp.Shell, ShizukuReason.CallFailed, t.toString(), t) @@ -440,7 +464,132 @@ object ShizukuApi { userServiceDeferred = CompletableDeferred() if (!wasBound || !::appContext.isInitialized) return Log.i(TAG, "Unbinding the shell service and asking Shizuku to stop it") - guard(ShizukuOp.Shell, Unit) { Shizuku.unbindUserService(serviceArgs, userServiceConnection, true) } + val args = boundArgs ?: serviceArgs + boundArgs = null + guard(ShizukuOp.Shell, Unit) { Shizuku.unbindUserService(args, userServiceConnection, true) } + } + + /** + * Chooses whether the next shell service is a daemon, letting go of one bound the other way. + * + * A service record's lifetime is fixed when it starts, so there is no changing it in place; the running one is + * released and the next call binds under the new rule. + */ + @Synchronized + private fun setShellDaemon(enabled: Boolean) { + if (daemonRequested == enabled) return + Log.i(TAG, "Shell service lifetime is now " + if (enabled) "daemon" else "tied to this app") + val hadService = userService != null + daemonRequested = enabled + if (hadService) releaseUserService() + } + + /** + * Arms the shell-side watchdog: the shell process starts [component] again whenever it finds no process of + * [packageName]. + * + * The user id is this app's own, so a manager installed in a secondary profile is restarted in the profile it lives + * in rather than in the primary one. + */ + suspend fun startManagerWatchdog(packageName: String, component: String, intervalSeconds: Int): Boolean { + setShellDaemon(true) + val userId = android.os.Process.myUid() / 100000 + val arm = { service: IShizukuService -> + service.startManagerWatchdog(packageName, component, userId, intervalSeconds) + } + if (onService(ShizukuOp.Shell, false, arm)) return true + // A daemon shell service outlives the app that asked for it -- that is the point of it -- so + // the one answering here can be from a previous life of this app, running code that predates + // this call and cannot serve it. Nothing distinguishes that from any other failure except + // trying again against a process this build started, so it is let go of exactly once. + Log.i(TAG, "The shell service could not arm the watchdog; replacing it and trying once more") + releaseUserService() + return onService(ShizukuOp.Shell, false, arm) + } + + /** Disarms the watchdog and lets the shell process go back to living only as long as this app does. */ + suspend fun stopManagerWatchdog() { + if (userService != null) { + onService(ShizukuOp.Shell, Unit) { it.stopManagerWatchdog() } + } + setShellDaemon(false) + } + + /** + * How a device answered one request to lift a limit. + * + * [Unsupported] is not a failure and must not be reported as one: several of these limits are a vendor's invention + * and simply do not exist elsewhere, so a device that has never heard of one has nothing to refuse. Telling a + * person their phone "refused" a setting it does not have sends them looking for a problem that is not there. + */ + enum class ShellVerdict { + Accepted, + Unsupported, + Refused, + } + + /** + * One command the shell was asked to run on the manager's behalf, and what it answered. + * + * [label] is what the limit is called, because the command is not what a reader wants to be told was refused; + * [command] and [output] stay for the log and for a report. + */ + data class ShellOutcome( + val label: String, + val command: String, + val output: String, + val verdict: ShellVerdict, + ) { + val accepted + get() = verdict == ShellVerdict.Accepted + } + + /** + * Asks the shell to take [packageName] out of the platform's background limits. + * + * Every one of these is a request the device is free to refuse -- the doze whitelist and the standby buckets are + * platform features, the auto-start op is not a platform feature at all and exists only on some vendors' builds -- + * so each is run on its own and reported as it answered. Nothing here is retried or assumed: what the reader is + * shown is what the shell said. + */ + suspend fun exemptFromBackgroundLimits(packageName: String): List { + val commands = + listOf( + appContext.getString(R.string.background_limit_doze) to "cmd deviceidle whitelist +$packageName", + appContext.getString(R.string.background_limit_background) to + "cmd appops set $packageName RUN_IN_BACKGROUND allow", + appContext.getString(R.string.background_limit_any_background) to + "cmd appops set $packageName RUN_ANY_IN_BACKGROUND allow", + appContext.getString(R.string.background_limit_standby) to "am set-standby-bucket $packageName active", + // Vendor-specific and absent from AOSP; asked for because on the devices that reap hardest + // it is the one that matters, and its refusal elsewhere is harmless and reported plainly. + appContext.getString(R.string.background_limit_autostart) to + "cmd appops set $packageName AUTO_START allow", + ) + return commands.map { (label, command) -> + val output = runShellCommand(command) + val verdict = if (output == null) ShellVerdict.Refused else verdictOf(output) + val outcome = ShellOutcome(label, command, output?.trim().orEmpty(), verdict) + if (verdict != ShellVerdict.Accepted) Log.i(TAG, "$label $verdict: $command -> ${outcome.output}") + outcome + } + } + + /** + * What a shell command's output says about itself. + * + * A command that printed nothing did what it was asked. Everything else is read for the one distinction worth + * making: a device that does not know the setting, versus one that knows it and said no. The first is how AOSP + * answers a vendor's app-op, and the wording is the platform's own ("Unknown operation string: AUTO_START"), so it + * is what there is to match on. + */ + private fun verdictOf(output: String): ShellVerdict { + val text = output.trim().lowercase() + if (text.isEmpty()) return ShellVerdict.Accepted + val absent = listOf("unknown operation", "unknown command", "not found", "no such", "unknown option") + if (absent.any { text.contains(it) }) return ShellVerdict.Unsupported + val refusals = listOf("error", "exception", "failure", "failed", "permission", "usage:", "bad ") + return if (refusals.any { text.contains(it) }) ShellVerdict.Refused else ShellVerdict.Accepted } /** diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml index 1e426936a..a1fbc36c6 100644 --- a/manager/src/main/res/values-ar/strings.xml +++ b/manager/src/main/res/values-ar/strings.xml @@ -273,4 +273,30 @@ الرجوع إلى الحزمة الأصلية رجوع يقوم بتنزيل أحدث manager.apk من إصدارات JingMatrix/LSPatch، وتثبيته باسم org.lsposed.lspatch (تتم استعادة الإعدادات وروابط التطبيقات المحلية)، ثم إلغاء تثبيت هذا التطبيق. يتطلب Shizuku واتصالًا بالشبكة. + LSPatch قيد التشغيل + يزوّد التطبيقات المعدّلة بوحداتها + أوقف هذا الجهاز LSPatch في الخلفية + بدأ %1$s %2$d مرة دون الوصول إلى LSPatch + تجاهل تحسين البطارية + تحسين البطارية معطّل بالفعل لتطبيق LSPatch + وضع Doze هو ما يمنع مديرًا لم يفتحه أحد اليوم من الاستجابة + فتح Shizuku + إبقاء LSPatch قابلاً للوصول + يطلب التطبيق المعدّل وحداته من LSPatch لحظة بدئه. فإن كان الجهاز قد أوقف LSPatch قبل ذلك، يعود التطبيق إلى قائمة الوحدات التي يتذكرها، ويضيع كل ما تغيّر هنا منذئذ. وكلا الإذنين غير إلزامي. + إظهار إشعار دائم + هو ما يُبقي LSPatch خارج أول مجموعة يفرغها النظام، وهو الدليل الوحيد على أنه يعمل + تم + رفع هذا الجهاز كل القيود المطلوبة + مرفوض: %1$s + إعادة التشغيل بعد الإيقاف + يستخدم صدفة Shizuku لتشغيل LSPatch من جديد كلما وجده متوقفًا. تبقى عملية الصدفة حية بعد LSPatch، لذا ينجح ذلك حتى بعد الإيقاف القسري. + يتطلب Shizuku + رفع قيود الخلفية + يطلب من الصدفة إخراج LSPatch من قائمة Doze ومن مجموعات انتظار التطبيقات، ويبلّغ بما رفضه الجهاز. + جارٍ سؤال الصدفة… + قائمة استثناءات Doze + العمل في الخلفية + أي عمل في الخلفية + مجموعة انتظار التطبيقات + التشغيل التلقائي من المصنّع diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml index 257006d39..1ffe51a7e 100644 --- a/manager/src/main/res/values-de/strings.xml +++ b/manager/src/main/res/values-de/strings.xml @@ -273,4 +273,30 @@ Auf ursprüngliches Paket zurücksetzen Zurücksetzen Lädt die neueste manager.apk aus den JingMatrix/LSPatch-Releases herunter, installiert sie als org.lsposed.lspatch (Einstellungen und Verknüpfungen der lokalen Apps werden wiederhergestellt) und deinstalliert anschließend diese App. Erfordert Shizuku und eine Netzwerkverbindung. + LSPatch läuft + Versorgt gepatchte Apps mit ihren Modulen + LSPatch wurde im Hintergrund beendet + %1$s startete %2$d mal, ohne LSPatch zu erreichen + Akku-Optimierung ignorieren + Die Akku-Optimierung ist für LSPatch bereits deaktiviert + Doze ist der Grund, warum ein heute von niemandem geöffneter Manager nicht antwortet + Shizuku öffnen + LSPatch erreichbar halten + Eine gepatchte App fragt LSPatch nach ihren Modulen, sobald sie startet. Hat dieses Gerät LSPatch bis dahin beendet, greift die App auf die gemerkte Modulliste zurück — und alles, was hier seitdem geändert wurde, fehlt. Beides ist nicht erforderlich. + Dauerhafte Benachrichtigung anzeigen + Das hält LSPatch aus der ersten Gruppe heraus, die das System leert — und ist das einzige Zeichen, dass es läuft + Fertig + Dieses Gerät hat jede angefragte Beschränkung aufgehoben + Abgelehnt: %1$s + Nach dem Beenden neu starten + Startet LSPatch über die Shizuku-Shell neu, sobald sie es beendet vorfindet. Der Shell-Prozess überlebt LSPatch und wirkt daher selbst nach einem erzwungenen Beenden. + Benötigt Shizuku + Hintergrund-Beschränkungen aufheben + Bittet die Shell, LSPatch von der Doze-Liste und aus den App-Standby-Buckets zu nehmen. Meldet, was das Gerät abgelehnt hat. + Shell wird gefragt… + Doze-Ausnahmeliste + Hintergrundausführung + Jede Hintergrundausführung + App-Standby-Bucket + Hersteller-Autostart diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml index 4852b4e93..1b377176c 100644 --- a/manager/src/main/res/values-es/strings.xml +++ b/manager/src/main/res/values-es/strings.xml @@ -273,4 +273,30 @@ Volver al paquete original Revertir Descarga el manager.apk más reciente de las publicaciones de JingMatrix/LSPatch, lo instala como org.lsposed.lspatch (se restauran los ajustes y los enlaces de las apps locales) y luego desinstala esta app. Requiere Shizuku y conexión de red. + LSPatch está activo + Entrega sus módulos a las aplicaciones parcheadas + Este dispositivo detuvo LSPatch en segundo plano + %1$s se inició %2$d veces sin alcanzar LSPatch + Ignorar la optimización de batería + La optimización de batería ya está desactivada para LSPatch + Doze es lo que impide responder a un gestor que nadie ha abierto hoy + Abrir Shizuku + Mantener LSPatch accesible + Una aplicación parcheada pide sus módulos a LSPatch en cuanto arranca. Si este dispositivo ya ha detenido LSPatch, la aplicación recurre a la lista de módulos que recuerda — y se pierde todo lo cambiado aquí desde entonces. Ninguno de los dos permisos es obligatorio. + Mostrar una notificación permanente + Es lo que mantiene a LSPatch fuera del primer grupo que el sistema vacía — y la única señal de que está funcionando + Listo + Este dispositivo levantó todos los límites solicitados + Rechazado: %1$s + Reiniciar cuando se detenga + Usa el shell de Shizuku para volver a iniciar LSPatch cada vez que lo encuentra detenido. El proceso del shell sobrevive a LSPatch, así que funciona incluso tras un cierre forzado. + Necesita Shizuku + Levantar los límites en segundo plano + Pide al shell que saque a LSPatch de la lista Doze y de los grupos de espera de aplicaciones. Informa de lo que el dispositivo rechazó. + Consultando al shell… + Lista blanca de Doze + Trabajo en segundo plano + Cualquier trabajo en segundo plano + Grupo de espera de aplicaciones + Inicio automático del fabricante diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml index 1859b23cf..5be88124a 100644 --- a/manager/src/main/res/values-fa/strings.xml +++ b/manager/src/main/res/values-fa/strings.xml @@ -273,4 +273,30 @@ بازگردانی به بستهٔ اصلی بازگردانی آخرین manager.apk را از انتشارهای JingMatrix/LSPatch دانلود می‌کند، آن را با نام org.lsposed.lspatch نصب می‌کند (تنظیمات و پیوندهای برنامه‌های محلی بازیابی می‌شوند) و سپس این برنامه را حذف می‌کند. به Shizuku و شبکه نیاز دارد. + LSPatch در حال اجراست + ماژول‌ها را به برنامه‌های وصله‌شده می‌دهد + این دستگاه LSPatch را در پس‌زمینه متوقف کرد + %1$s بدون دسترسی به LSPatch %2$d بار اجرا شد + نادیده گرفتن بهینه‌سازی باتری + بهینه‌سازی باتری برای LSPatch از پیش غیرفعال است + همین Doze است که نمی‌گذارد مدیری که امروز کسی بازش نکرده پاسخ دهد + باز کردن Shizuku + در دسترس نگه داشتن LSPatch + برنامهٔ وصله‌شده به‌محض اجرا ماژول‌هایش را از LSPatch می‌خواهد. اگر دستگاه تا آن لحظه LSPatch را متوقف کرده باشد، برنامه به فهرست ماژول‌هایی که به یاد دارد بازمی‌گردد و هر تغییری که از آن پس اینجا داده‌اید از دست می‌رود. هیچ‌کدام از این دو الزامی نیست. + نمایش اعلان دائمی + همین است که LSPatch را از نخستین گروهی که سیستم خالی می‌کند بیرون نگه می‌دارد و تنها نشانهٔ در حال اجرا بودن آن است + تمام + این دستگاه همهٔ محدودیت‌های خواسته‌شده را برداشت + رد شد: %1$s + راه‌اندازی دوباره پس از توقف + با پوستهٔ Shizuku هر بار که LSPatch را متوقف بیابد دوباره اجرایش می‌کند. فرایند پوسته پس از LSPatch هم زنده می‌ماند، پس حتی بعد از توقف اجباری کار می‌کند. + به Shizuku نیاز دارد + برداشتن محدودیت‌های پس‌زمینه + از پوسته می‌خواهد LSPatch را از فهرست Doze و از گروه‌های آماده‌باش برنامه‌ها بیرون بیاورد و گزارش می‌دهد دستگاه چه چیزی را رد کرده است. + در حال پرسش از پوسته… + فهرست استثنای Doze + کار در پس‌زمینه + هر کاری در پس‌زمینه + گروه آماده‌باش برنامه‌ها + اجرای خودکار سازنده diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml index f2edba928..35b8bd507 100644 --- a/manager/src/main/res/values-fr/strings.xml +++ b/manager/src/main/res/values-fr/strings.xml @@ -273,4 +273,30 @@ Revenir au paquet d\'origine Rétablir Télécharge le dernier manager.apk depuis les versions JingMatrix/LSPatch, l\'installe en tant que org.lsposed.lspatch (les paramètres et les liens des applications locales sont restaurés), puis désinstalle cette application. Nécessite Shizuku et le réseau. + LSPatch est actif + Fournit leurs modules aux applications patchées + LSPatch a été arrêté en arrière-plan + %1$s a démarré %2$d fois sans joindre LSPatch + Ignorer l\'optimisation de la batterie + L\'optimisation de la batterie est déjà désactivée pour LSPatch + Le mode Doze est ce qui empêche un gestionnaire ouvert par personne aujourd\'hui de répondre + Ouvrir Shizuku + Garder LSPatch joignable + Une application patchée demande ses modules à LSPatch dès qu\'elle démarre. Si cet appareil a déjà arrêté LSPatch, l\'application se rabat sur la liste de modules qu\'elle a mémorisée — et tout ce qui a changé ici depuis est ignoré. Aucune de ces autorisations n\'est obligatoire. + Afficher une notification persistante + Ce qui garde LSPatch hors du premier lot que le système vide — et le seul signe qu\'il tourne + Terminé + Cet appareil a levé toutes les limites demandées + Refusé : %1$s + Redémarrer après un arrêt + Utilise le shell Shizuku pour relancer LSPatch dès qu\'il le trouve arrêté. Le processus du shell survit à LSPatch, donc cela fonctionne même après un arrêt forcé. + Nécessite Shizuku + Lever les limites d\'arrière-plan + Demande au shell de retirer LSPatch de la liste Doze et des paliers de veille des applications. Indique ce que l\'appareil a refusé. + Demande au shell… + Liste blanche Doze + Travail en arrière-plan + Tout travail en arrière-plan + Palier de veille des applications + Démarrage auto du constructeur diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml index 40f5e4548..ce2de598c 100644 --- a/manager/src/main/res/values-in/strings.xml +++ b/manager/src/main/res/values-in/strings.xml @@ -273,4 +273,30 @@ Kembalikan ke paket asli Kembalikan Mengunduh manager.apk terbaru dari rilis JingMatrix/LSPatch, memasangnya sebagai org.lsposed.lspatch (pengaturan dan tautan aplikasi lokal dipulihkan), lalu mencopot aplikasi ini. Memerlukan Shizuku dan jaringan. + LSPatch sedang berjalan + Menyediakan modul untuk aplikasi yang telah ditambal + Perangkat ini menghentikan LSPatch di latar belakang + %1$s dimulai %2$d kali tanpa berhasil menghubungi LSPatch + Abaikan pengoptimalan baterai + Pengoptimalan baterai sudah dimatikan untuk LSPatch + Doze-lah yang membuat pengelola yang hari ini tidak dibuka siapa pun tidak dapat menjawab + Buka Shizuku + Jaga agar LSPatch tetap terjangkau + Aplikasi yang ditambal meminta modulnya kepada LSPatch begitu ia dijalankan. Jika perangkat sudah menghentikan LSPatch saat itu, aplikasi memakai daftar modul yang diingatnya — dan semua perubahan di sini sejak itu terlewat. Keduanya tidak wajib. + Tampilkan notifikasi permanen + Inilah yang menjaga LSPatch dari kelompok pertama yang dibersihkan sistem — dan satu-satunya tanda bahwa ia berjalan + Selesai + Perangkat ini mencabut semua batasan yang diminta + Ditolak: %1$s + Mulai ulang saat dihentikan + Memakai shell Shizuku untuk menjalankan LSPatch lagi setiap kali mendapatinya hilang. Proses shell hidup lebih lama daripada LSPatch, jadi ini tetap bekerja setelah penghentian paksa. + Perlu Shizuku + Cabut batasan latar belakang + Meminta shell mengeluarkan LSPatch dari daftar Doze dan dari bucket siaga aplikasi. Melaporkan apa yang ditolak perangkat. + Meminta ke shell… + Daftar pengecualian Doze + Kerja di latar belakang + Segala kerja di latar belakang + Bucket siaga aplikasi + Mulai otomatis dari vendor diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml index 66eb85a2d..33272bbb4 100644 --- a/manager/src/main/res/values-it/strings.xml +++ b/manager/src/main/res/values-it/strings.xml @@ -273,4 +273,30 @@ Ripristina il pacchetto originale Ripristina Scarica l\'ultimo manager.apk dalle release di JingMatrix/LSPatch, lo installa come org.lsposed.lspatch (impostazioni e collegamenti delle app locali vengono ripristinati), quindi disinstalla questa app. Richiede Shizuku e la rete. + LSPatch è in esecuzione + Fornisce i moduli alle app patchate + Questo dispositivo ha fermato LSPatch in background + %1$s è partita %2$d volte senza raggiungere LSPatch + Ignora l\'ottimizzazione della batteria + L\'ottimizzazione della batteria è già disattivata per LSPatch + Doze è ciò che impedisce di rispondere a un gestore che oggi nessuno ha aperto + Apri Shizuku + Mantieni LSPatch raggiungibile + Un\'app patchata chiede i suoi moduli a LSPatch nel momento in cui parte. Se il dispositivo ha già fermato LSPatch, l\'app ricade sull\'elenco di moduli che ricorda — e tutto ciò che è cambiato qui nel frattempo va perso. Nessuno dei due permessi è obbligatorio. + Mostra una notifica permanente + È ciò che tiene LSPatch fuori dal primo gruppo che il sistema svuota — e l\'unico segno che è in funzione + Fatto + Questo dispositivo ha rimosso tutti i limiti richiesti + Rifiutato: %1$s + Riavvia quando viene fermato + Usa la shell di Shizuku per riavviare LSPatch ogni volta che lo trova fermo. Il processo della shell sopravvive a LSPatch, quindi funziona anche dopo un arresto forzato. + Richiede Shizuku + Rimuovi i limiti in background + Chiede alla shell di togliere LSPatch dall\'elenco Doze e dai bucket di standby delle app. Riporta ciò che il dispositivo ha rifiutato. + Richiesta alla shell… + Lista consentiti Doze + Lavoro in background + Qualsiasi lavoro in background + Bucket di standby delle app + Avvio automatico del produttore diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml index 009a6a765..6612257d3 100644 --- a/manager/src/main/res/values-iw/strings.xml +++ b/manager/src/main/res/values-iw/strings.xml @@ -273,4 +273,30 @@ שחזור לחבילה המקורית שחזור מוריד את manager.apk העדכני ביותר מגרסאות JingMatrix/LSPatch, מתקין אותו בשם org.lsposed.lspatch (ההגדרות וקישורי האפליקציות המקומיות משוחזרים), ולאחר מכן מסיר אפליקציה זו. דורש Shizuku ורשת. + LSPatch פועל + מספק מודולים לאפליקציות המתוקנות + המכשיר הזה עצר את LSPatch ברקע + %1$s הופעלה %2$d פעמים בלי להגיע ל-LSPatch + התעלם מאופטימיזציית הסוללה + אופטימיזציית הסוללה כבר מכובה עבור LSPatch + Doze הוא מה שמונע ממנהל שאיש לא פתח היום להשיב בכלל + פתיחת Shizuku + לשמור על LSPatch נגיש + אפליקציה מתוקנת מבקשת מ-LSPatch את המודולים שלה ברגע שהיא עולה. אם עד אז המכשיר כבר עצר את LSPatch, האפליקציה נשענת על רשימת המודולים שהיא זוכרת — וכל מה שהשתנה כאן מאז מוחמץ. אף אחת מההרשאות אינה חובה. + הצגת התראה קבועה + זה מה ששומר על LSPatch מחוץ לקבוצה הראשונה שהמערכת מרוקנת — והסימן היחיד שהוא פועל + סיום + המכשיר הסיר את כל המגבלות שהתבקשו + נדחה: %1$s + הפעלה מחדש לאחר עצירה + משתמש במעטפת של Shizuku כדי להפעיל את LSPatch מחדש בכל פעם שהוא נמצא עצור. תהליך המעטפת שורד את LSPatch, ולכן זה עובד גם אחרי עצירה כפויה. + דורש Shizuku + הסרת מגבלות רקע + מבקש מהמעטפת להוציא את LSPatch מרשימת Doze ומקבוצות ההמתנה של האפליקציות, ומדווח מה המכשיר סירב. + פונה למעטפת… + רשימת החריגים של Doze + עבודה ברקע + כל עבודה ברקע + קבוצת המתנה של אפליקציות + הפעלה אוטומטית של היצרן diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml index 967112a44..02b520f4f 100644 --- a/manager/src/main/res/values-ja/strings.xml +++ b/manager/src/main/res/values-ja/strings.xml @@ -273,4 +273,30 @@ 元のパッケージに戻す 元に戻す JingMatrix/LSPatch のリリースから最新の manager.apk をダウンロードし、org.lsposed.lspatch としてインストールして(設定とローカルアプリのリンクが復元されます)、このアプリをアンインストールします。Shizuku とネットワークが必要です。 + LSPatch は動作中です + パッチ済みアプリにモジュールを提供しています + この端末がバックグラウンドで LSPatch を停止しました + %1$s は LSPatch に届かないまま %2$d 回起動しました + 電池の最適化を無視する + LSPatch は既に電池の最適化から除外されています + 今日誰も開いていないマネージャーが応答できないのは Doze のためです + Shizuku を開く + LSPatch を応答できる状態に保つ + パッチ済みアプリは起動した瞬間に LSPatch へモジュールを要求します。その時点で端末が LSPatch を停止していれば、アプリは記憶しているモジュール一覧に頼ることになり、以後ここで変更した内容は反映されません。どちらも必須ではありません。 + 常駐通知を表示する + システムが最初に整理する対象から LSPatch を外すもので、動作中であることを示す唯一の印です + 完了 + この端末は要求したすべての制限を解除しました + 拒否: %1$s + 停止されたら再起動する + Shizuku のシェルを使い、LSPatch が消えていれば起動し直します。シェルのプロセスは LSPatch より長く残るため、強制停止の後でも機能します。 + Shizuku が必要です + バックグラウンド制限を解除する + Doze リストとアプリスタンバイバケットから LSPatch を外すようシェルに依頼し、端末が拒否した項目をそのまま報告します。 + シェルに依頼中… + Doze の除外リスト + バックグラウンド動作 + あらゆるバックグラウンド動作 + アプリスタンバイバケット + メーカーの自動起動 diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml index 15931126a..e197b3936 100644 --- a/manager/src/main/res/values-ko/strings.xml +++ b/manager/src/main/res/values-ko/strings.xml @@ -273,4 +273,30 @@ 원래 패키지로 되돌리기 되돌리기 JingMatrix/LSPatch 릴리스에서 최신 manager.apk를 다운로드하여 org.lsposed.lspatch로 설치하고(설정과 로컬 앱 링크가 복원됨) 이 앱을 제거합니다. Shizuku와 네트워크가 필요합니다. + LSPatch 실행 중 + 패치된 앱에 모듈을 제공하는 중 + 이 기기가 백그라운드에서 LSPatch를 중지했습니다 + %1$s이(가) LSPatch에 닿지 못한 채 %2$d번 실행되었습니다 + 배터리 최적화 무시 + LSPatch는 이미 배터리 최적화에서 제외되어 있습니다 + 오늘 아무도 열지 않은 관리자가 아예 응답하지 못하는 것은 Doze 때문입니다 + Shizuku 열기 + LSPatch를 연결 가능한 상태로 유지 + 패치된 앱은 실행되는 순간 LSPatch에 모듈을 요청합니다. 그때 기기가 이미 LSPatch를 중지했다면 앱은 기억해 둔 모듈 목록에 의존하게 되고, 그 이후 여기서 바꾼 내용은 반영되지 않습니다. 둘 다 필수는 아닙니다. + 상시 알림 표시 + 시스템이 가장 먼저 정리하는 대상에서 LSPatch를 빼 주며, 실행 중임을 알 수 있는 유일한 표시입니다 + 완료 + 이 기기가 요청한 제한을 모두 해제했습니다 + 거부됨: %1$s + 중지되면 다시 시작 + Shizuku 셸을 이용해 LSPatch가 사라진 것을 발견하면 다시 시작합니다. 셸 프로세스는 LSPatch보다 오래 남으므로 강제 중지 후에도 동작합니다. + Shizuku 필요 + 백그라운드 제한 해제 + Doze 목록과 앱 대기 버킷에서 LSPatch를 빼도록 셸에 요청하고, 기기가 거부한 항목을 그대로 알려 줍니다. + 셸에 요청하는 중… + Doze 허용 목록 + 백그라운드 작업 + 모든 백그라운드 작업 + 앱 대기 버킷 + 제조사 자동 실행 diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml index 1e6642849..fe30381be 100644 --- a/manager/src/main/res/values-pl/strings.xml +++ b/manager/src/main/res/values-pl/strings.xml @@ -273,4 +273,30 @@ Przywróć oryginalny pakiet Przywróć Pobiera najnowszy plik manager.apk z wydań JingMatrix/LSPatch, instaluje go jako org.lsposed.lspatch (ustawienia i powiązania aplikacji lokalnych zostają przywrócone), a następnie odinstalowuje tę aplikację. Wymaga Shizuku i sieci. + LSPatch działa + Dostarcza moduły załatanym aplikacjom + To urządzenie zatrzymało LSPatch w tle + %1$s uruchomiono %2$d razy bez połączenia z LSPatch + Ignoruj optymalizację baterii + Optymalizacja baterii jest już wyłączona dla LSPatch + To Doze sprawia, że menedżer, którego nikt dziś nie otworzył, w ogóle nie odpowiada + Otwórz Shizuku + Utrzymuj dostępność LSPatch + Załatana aplikacja pyta LSPatch o swoje moduły w chwili uruchomienia. Jeśli urządzenie zdążyło już zatrzymać LSPatch, aplikacja korzysta z zapamiętanej listy modułów — a wszystko, co zmieniono tu od tamtej pory, zostaje pominięte. Żadne z tych uprawnień nie jest wymagane. + Pokazuj stałe powiadomienie + To ono trzyma LSPatch poza pierwszą grupą, którą system opróżnia — i jest jedynym znakiem, że działa + Gotowe + To urządzenie zniosło wszystkie żądane ograniczenia + Odmówiono: %1$s + Uruchom ponownie po zatrzymaniu + Używa powłoki Shizuku, aby uruchomić LSPatch ponownie, gdy tylko zastanie go zatrzymanym. Proces powłoki żyje dłużej niż LSPatch, więc działa to nawet po wymuszonym zatrzymaniu. + Wymaga Shizuku + Znieś ograniczenia w tle + Prosi powłokę o usunięcie LSPatch z listy Doze i z grup uśpienia aplikacji. Zgłasza, czego urządzenie odmówiło. + Pytanie powłoki… + Lista wyjątków Doze + Praca w tle + Dowolna praca w tle + Grupa uśpienia aplikacji + Autostart producenta diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml index d40e7a188..017bf818b 100644 --- a/manager/src/main/res/values-pt-rBR/strings.xml +++ b/manager/src/main/res/values-pt-rBR/strings.xml @@ -273,4 +273,30 @@ Reverter para o pacote original Reverter Baixa o manager.apk mais recente das versões do JingMatrix/LSPatch, instala-o como org.lsposed.lspatch (as configurações e os vínculos dos apps locais são restaurados) e depois desinstala este app. Requer Shizuku e rede. + O LSPatch está em execução + Entrega os módulos aos aplicativos corrigidos + Este dispositivo parou o LSPatch em segundo plano + %1$s iniciou %2$d vezes sem alcançar o LSPatch + Ignorar a otimização de bateria + A otimização de bateria já está desativada para o LSPatch + O Doze é o que impede um gerenciador que ninguém abriu hoje de responder + Abrir o Shizuku + Manter o LSPatch acessível + Um aplicativo corrigido pede seus módulos ao LSPatch assim que inicia. Se este dispositivo já tiver parado o LSPatch, o aplicativo recorre à lista de módulos que memorizou — e tudo o que mudou aqui desde então se perde. Nenhuma das duas permissões é obrigatória. + Mostrar uma notificação permanente + É o que mantém o LSPatch fora do primeiro grupo que o sistema esvazia — e o único sinal de que ele está rodando + Concluído + Este dispositivo removeu todos os limites solicitados + Recusado: %1$s + Reiniciar quando for parado + Usa o shell do Shizuku para iniciar o LSPatch de novo sempre que o encontra parado. O processo do shell sobrevive ao LSPatch, então funciona até depois de uma parada forçada. + Requer o Shizuku + Remover limites de segundo plano + Pede ao shell para tirar o LSPatch da lista do Doze e dos grupos de espera de aplicativos. Informa o que o dispositivo recusou. + Consultando o shell… + Lista de isenção do Doze + Trabalho em segundo plano + Qualquer trabalho em segundo plano + Grupo de espera de aplicativos + Início automático do fabricante diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml index b8cdd731f..9843554c3 100644 --- a/manager/src/main/res/values-ru/strings.xml +++ b/manager/src/main/res/values-ru/strings.xml @@ -273,4 +273,30 @@ Вернуть исходный пакет Вернуть Загружает последний manager.apk из релизов JingMatrix/LSPatch, устанавливает его как org.lsposed.lspatch (настройки и связи локальных приложений восстанавливаются), затем удаляет это приложение. Требуются Shizuku и сеть. + LSPatch работает + Выдаёт модули пропатченным приложениям + Это устройство остановило LSPatch в фоне + %1$s запускалось %2$d раз(а) без связи с LSPatch + Игнорировать оптимизацию батареи + Оптимизация батареи для LSPatch уже отключена + Именно Doze мешает ответить менеджеру, который сегодня никто не открывал + Открыть Shizuku + Держать LSPatch доступным + Пропатченное приложение запрашивает у LSPatch свои модули сразу при запуске. Если к этому моменту устройство остановило LSPatch, приложение берёт запомненный список модулей — и всё, что изменено здесь с тех пор, теряется. Ни одно из этих разрешений не обязательно. + Показывать постоянное уведомление + Это удерживает LSPatch вне первой группы, которую система выгружает, — и единственный признак того, что он работает + Готово + Устройство сняло все запрошенные ограничения + Отклонено: %1$s + Перезапускать после остановки + Через шелл Shizuku запускает LSPatch заново, как только находит его остановленным. Процесс шелла живёт дольше LSPatch, поэтому это работает даже после принудительной остановки. + Требуется Shizuku + Снять фоновые ограничения + Просит шелл убрать LSPatch из списка Doze и из групп ожидания приложений. Сообщает, в чём устройство отказало. + Запрос к шеллу… + Белый список Doze + Работа в фоне + Любая работа в фоне + Группа ожидания приложений + Автозапуск производителя diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml index 4ff565e09..f394c467c 100644 --- a/manager/src/main/res/values-tr/strings.xml +++ b/manager/src/main/res/values-tr/strings.xml @@ -273,4 +273,30 @@ Özgün pakete geri dön Geri al JingMatrix/LSPatch sürümlerinden en yeni manager.apk dosyasını indirir, org.lsposed.lspatch olarak yükler (ayarlar ve yerel uygulama bağlantıları geri yüklenir) ve ardından bu uygulamayı kaldırır. Shizuku ve ağ gerektirir. + LSPatch çalışıyor + Yamalı uygulamalara modüllerini veriyor + Bu cihaz LSPatch\'i arka planda durdurdu + %1$s, LSPatch\'e ulaşamadan %2$d kez başlatıldı + Pil optimizasyonunu yok say + LSPatch için pil optimizasyonu zaten kapalı + Bugün kimsenin açmadığı bir yöneticinin hiç yanıt verememesinin nedeni Doze\'dur + Shizuku\'yu aç + LSPatch\'i ulaşılabilir tut + Yamalı bir uygulama başlar başlamaz modüllerini LSPatch\'ten ister. Cihaz o ana kadar LSPatch\'i durdurmuşsa, uygulama hatırladığı modül listesine döner — ve o zamandan beri burada değişen her şey kaçırılır. Bunların ikisi de zorunlu değildir. + Kalıcı bir bildirim göster + LSPatch\'i sistemin ilk boşalttığı gruptan uzak tutan şey budur — ve çalıştığının tek işareti + Bitti + Bu cihaz istenen tüm sınırları kaldırdı + Reddedildi: %1$s + Durdurulunca yeniden başlat + LSPatch\'i kaybolmuş bulduğunda Shizuku kabuğunu kullanarak yeniden başlatır. Kabuk süreci LSPatch\'ten daha uzun yaşar, bu yüzden zorla durdurmadan sonra da çalışır. + Shizuku gerekir + Arka plan sınırlarını kaldır + Kabuktan LSPatch\'i Doze listesinden ve uygulama bekleme gruplarından çıkarmasını ister. Cihazın neyi reddettiğini bildirir. + Kabuğa soruluyor… + Doze izin listesi + Arka planda çalışma + Her türlü arka plan çalışması + Uygulama bekleme grubu + Üretici otomatik başlatma diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml index 31a136260..ad70f6f5f 100644 --- a/manager/src/main/res/values-uk/strings.xml +++ b/manager/src/main/res/values-uk/strings.xml @@ -273,4 +273,30 @@ Повернути початковий пакет Повернути Завантажує найновіший manager.apk із випусків JingMatrix/LSPatch, встановлює його як org.lsposed.lspatch (налаштування та зв\'язки локальних застосунків відновлюються), а потім видаляє цей застосунок. Потребує Shizuku та мережі. + LSPatch працює + Видає модулі пропатченим застосункам + Цей пристрій зупинив LSPatch у фоні + %1$s запускався %2$d раз(и) без зв\'язку з LSPatch + Ігнорувати оптимізацію батареї + Оптимізацію батареї для LSPatch уже вимкнено + Саме Doze не дає відповісти менеджеру, якого сьогодні ніхто не відкривав + Відкрити Shizuku + Тримати LSPatch доступним + Пропатчений застосунок запитує в LSPatch свої модулі щойно запускається. Якщо на той час пристрій уже зупинив LSPatch, застосунок бере запам\'ятований список модулів — і все, що змінено тут відтоді, буде пропущено. Жоден із цих дозволів не є обов\'язковим. + Показувати постійне сповіщення + Саме воно тримає LSPatch поза першою групою, яку система вивантажує, — і єдина ознака, що він працює + Готово + Пристрій зняв усі запитані обмеження + Відхилено: %1$s + Перезапускати після зупинки + Через оболонку Shizuku запускає LSPatch знову, щойно знаходить його зупиненим. Процес оболонки живе довше за LSPatch, тож це діє навіть після примусової зупинки. + Потрібен Shizuku + Зняти фонові обмеження + Просить оболонку прибрати LSPatch зі списку Doze і з груп очікування застосунків. Повідомляє, у чому пристрій відмовив. + Запит до оболонки… + Білий список Doze + Робота у фоні + Будь-яка робота у фоні + Група очікування застосунків + Автозапуск виробника diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml index 724ad5246..d088962d9 100644 --- a/manager/src/main/res/values-vi/strings.xml +++ b/manager/src/main/res/values-vi/strings.xml @@ -273,4 +273,30 @@ Khôi phục về gói gốc Khôi phục Tải manager.apk mới nhất từ các bản phát hành JingMatrix/LSPatch, cài đặt dưới tên org.lsposed.lspatch (khôi phục cài đặt và liên kết ứng dụng cục bộ), rồi gỡ cài đặt ứng dụng này. Yêu cầu Shizuku và mạng. + LSPatch đang chạy + Đang cung cấp mô-đun cho các ứng dụng đã vá + Thiết bị này đã dừng LSPatch ở nền + %1$s đã khởi động %2$d lần mà không liên hệ được LSPatch + Bỏ qua tối ưu hoá pin + LSPatch đã được miễn tối ưu hoá pin + Doze chính là thứ khiến một trình quản lý mà hôm nay chưa ai mở không thể trả lời + Mở Shizuku + Giữ cho LSPatch luôn liên hệ được + Một ứng dụng đã vá sẽ hỏi LSPatch về các mô-đun của nó ngay khi khởi động. Nếu lúc đó thiết bị đã dừng LSPatch, ứng dụng phải dùng danh sách mô-đun mà nó nhớ — và mọi thay đổi ở đây kể từ đó sẽ bị bỏ lỡ. Cả hai đều không bắt buộc. + Hiển thị thông báo thường trực + Đây là thứ giữ LSPatch khỏi nhóm bị hệ thống dọn đầu tiên — và là dấu hiệu duy nhất cho thấy nó đang chạy + Xong + Thiết bị này đã gỡ mọi giới hạn được yêu cầu + Bị từ chối: %1$s + Khởi động lại khi bị dừng + Dùng shell của Shizuku để khởi động lại LSPatch mỗi khi thấy nó biến mất. Tiến trình shell sống lâu hơn LSPatch nên vẫn hiệu quả sau khi buộc dừng. + Cần Shizuku + Gỡ giới hạn chạy nền + Yêu cầu shell đưa LSPatch ra khỏi danh sách Doze và các nhóm chờ ứng dụng. Báo lại đúng những gì thiết bị đã từ chối. + Đang hỏi shell… + Danh sách miễn trừ Doze + Chạy nền + Mọi hoạt động nền + Nhóm chờ ứng dụng + Tự khởi động của nhà sản xuất diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml index 40b2d4c4f..18c8e2292 100644 --- a/manager/src/main/res/values-zh-rCN/strings.xml +++ b/manager/src/main/res/values-zh-rCN/strings.xml @@ -275,4 +275,30 @@ 还原为原始包名 还原 从 JingMatrix/LSPatch 的发行版下载最新的 manager.apk,将其安装为 org.lsposed.lspatch(会还原设置和本地应用的关联),然后卸载此应用。需要 Shizuku 和网络。 + LSPatch 正在运行 + 正在为已修补应用提供模块 + LSPatch 曾在后台被停止 + %1$s 有 %2$d 次启动时未能连上 LSPatch + 忽略电池优化 + LSPatch 已不受电池优化限制 + 今天没人打开过的管理器之所以完全无法响应,正是因为 Doze + 打开 Shizuku + 让 LSPatch 保持可达 + 已修补的应用一启动就会向 LSPatch 索取模块。若此时本机已停止 LSPatch,应用只能退回到自己记住的模块列表——此后在这里做的改动都不会生效。以下两项都不是必需的。 + 显示常驻通知 + 它让 LSPatch 不落入系统最先清理的那一批——也是它仍在运行的唯一凭据 + 完成 + 本机已解除所有请求的限制 + 被拒绝:%1$s + 被停止后自动重启 + 借助 Shizuku 的 shell,一旦发现 LSPatch 已消失就将其重新启动。shell 进程比 LSPatch 活得更久,因此强行停止后依然有效。 + 需要 Shizuku + 解除后台限制 + 请求 shell 将 LSPatch 移出 Doze 名单与应用待机分组,并如实报告本机拒绝了哪些。 + 正在请求 shell… + Doze 白名单 + 后台运行 + 任意后台运行 + 应用待机分组 + 厂商自启动 diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml index 2f33573ac..98f99ebc8 100644 --- a/manager/src/main/res/values-zh-rTW/strings.xml +++ b/manager/src/main/res/values-zh-rTW/strings.xml @@ -273,4 +273,30 @@ 還原為原始套件 還原 從 JingMatrix/LSPatch 的發行版下載最新的 manager.apk,將其安裝為 org.lsposed.lspatch(會還原設定與本機應用程式的關聯),然後解除安裝此應用程式。需要 Shizuku 與網路。 + LSPatch 正在執行 + 正在為已修補的應用程式提供模組 + LSPatch 曾在背景被停止 + %1$s 有 %2$d 次啟動時未能連上 LSPatch + 忽略電池最佳化 + LSPatch 已不受電池最佳化限制 + 今天沒人開啟過的管理器之所以完全無法回應,正是因為 Doze + 開啟 Shizuku + 讓 LSPatch 保持可連線 + 已修補的應用程式一啟動就會向 LSPatch 索取模組。若此時本機已停止 LSPatch,應用程式只能退回自己記住的模組清單——此後在這裡所做的變更都不會生效。以下兩項都不是必要的。 + 顯示常駐通知 + 它讓 LSPatch 不落入系統最先清理的那一批——也是它仍在執行的唯一憑據 + 完成 + 本機已解除所有請求的限制 + 遭拒絕:%1$s + 被停止後自動重新啟動 + 透過 Shizuku 的 shell,一旦發現 LSPatch 已消失便將其重新啟動。shell 行程比 LSPatch 活得更久,因此強制停止後依然有效。 + 需要 Shizuku + 解除背景限制 + 請求 shell 將 LSPatch 移出 Doze 名單與應用待機分組,並如實回報本機拒絕了哪些。 + 正在請求 shell… + Doze 白名單 + 背景執行 + 任意背景執行 + 應用待機分組 + 廠商自動啟動 diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index b263496fc..5a60061ed 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -279,4 +279,34 @@ No release notes Back Retry + + LSPatch is running + Serving modules to patched apps + + + LSPatch was stopped in the background + %1$s started %2$d time(s) without reaching LSPatch + Ignore battery optimisation + Battery optimisation is already off for LSPatch + Open Shizuku + Keep LSPatch reachable + A patched app asks LSPatch for its modules the moment it starts. If this device has stopped LSPatch by then, the app falls back to the module list it remembers — and anything changed here since is missed. Neither of these is required. + Show an ongoing notification + What keeps LSPatch out of the first bucket the system empties — and the only sign it is running + Done + This device lifted every limit asked for + Refused: %1$s + Doze is what stops a manager nobody opened today from answering + Restart when stopped + Uses the Shizuku shell to start LSPatch again whenever it finds it gone. The shell process outlives LSPatch, so it works even after a force stop. + Needs Shizuku + Lift background limits + Asks the shell to take LSPatch off the doze list and out of the app-standby buckets. Reports what the device refused. + Asking the shell… + + Doze whitelist + Background work + Any background work + App standby bucket + Vendor auto-start diff --git a/patch-loader/src/main/java/org/lsposed/lspatch/service/LocalApplicationService.java b/patch-loader/src/main/java/org/lsposed/lspatch/service/LocalApplicationService.java index ee26686c4..b663629cb 100644 --- a/patch-loader/src/main/java/org/lsposed/lspatch/service/LocalApplicationService.java +++ b/patch-loader/src/main/java/org/lsposed/lspatch/service/LocalApplicationService.java @@ -1,21 +1,11 @@ package org.lsposed.lspatch.service; import android.content.Context; -import android.content.pm.ApplicationInfo; import android.os.Environment; import android.os.IBinder; import android.os.ParcelFileDescriptor; import android.util.Log; - -import org.lsposed.lspatch.loader.util.FileUtils; -import org.lsposed.lspatch.share.Constants; -import org.lsposed.lspatch.util.ModuleLoader; -import org.matrix.vector.ipc.IFrameworkService; -import org.matrix.vector.ipc.IProcessChannel; -import org.matrix.vector.ipc.LoadedModule; - import io.github.libxposed.service.IXposedService; - import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -24,6 +14,12 @@ import java.util.List; import java.util.stream.Collectors; import java.util.zip.ZipFile; +import org.lsposed.lspatch.loader.util.FileUtils; +import org.lsposed.lspatch.share.Constants; +import org.lsposed.lspatch.util.LoadedModules; +import org.matrix.vector.ipc.IFrameworkService; +import org.matrix.vector.ipc.IProcessChannel; +import org.matrix.vector.ipc.LoadedModule; /** * The {@link IFrameworkService} for embedded (no-manager) mode: it serves the modules the patcher @@ -51,7 +47,10 @@ public LocalApplicationService(Context context) { String modulePath = context.getCacheDir() + "/lspatch/" + packageName + "/"; String cacheApkPath; try (ZipFile sourceFile = new ZipFile(context.getPackageResourcePath())) { - cacheApkPath = modulePath + sourceFile.getEntry(Constants.EMBEDDED_MODULES_ASSET_PATH + name).getCrc() + ".apk"; + cacheApkPath = modulePath + + sourceFile + .getEntry(Constants.EMBEDDED_MODULES_ASSET_PATH + name) + .getCrc() + ".apk"; } if (!Files.exists(Paths.get(cacheApkPath))) { @@ -63,21 +62,18 @@ public LocalApplicationService(Context context) { } } - var code = ModuleLoader.loadModule(cacheApkPath); - if (code == null) { - Log.w(TAG, "Failed to load module " + packageName); - continue; - } - - var module = new LoadedModule(); - module.packageName = packageName; - module.apkPath = cacheApkPath; - module.appId = -1; - module.versionCode = 0; - module.code = code; - module.applicationInfo = syntheticApplicationInfo(packageName, cacheApkPath); - module.service = EmbeddedRemoteServices.get(context) - .moduleService(packageName, IXposedService.PROP_CAP_REMOTE); + // Not installed as an app, so PackageManager can describe neither its identity (appId, + // version code) nor where it lives; the synthetic ApplicationInfo carries what the + // framework actually reads. + var module = LoadedModules.fromApk( + packageName, + cacheApkPath, + -1, + 0, + LoadedModules.syntheticApplicationInfo(packageName, cacheApkPath, null), + null, + EmbeddedRemoteServices.get(context).moduleService(packageName, IXposedService.PROP_CAP_REMOTE)); + if (module == null) continue; modules.add(module); } catch (Throwable e) { Log.e(TAG, "Error loading embedded module " + name, e); @@ -85,17 +81,6 @@ public LocalApplicationService(Context context) { } } - // The module is not installed as an app in embedded mode, so PackageManager cannot describe it. - // The framework only reads packageName and sourceDir off this (getModuleApplicationInfo, and the - // in-APK native library path), so a synthetic ApplicationInfo carrying those is enough. - private static ApplicationInfo syntheticApplicationInfo(String packageName, String apkPath) { - var info = new ApplicationInfo(); - info.packageName = packageName; - info.sourceDir = apkPath; - info.publicSourceDir = apkPath; - return info; - } - @Override public boolean isLogMuted() { return false; diff --git a/patch-loader/src/main/java/org/lsposed/lspatch/service/ModuleDeliveryLog.java b/patch-loader/src/main/java/org/lsposed/lspatch/service/ModuleDeliveryLog.java new file mode 100644 index 000000000..541d7794a --- /dev/null +++ b/patch-loader/src/main/java/org/lsposed/lspatch/service/ModuleDeliveryLog.java @@ -0,0 +1,82 @@ +package org.lsposed.lspatch.service; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.util.Log; + +/** + * How often this app started without the manager answering, kept until the manager can be told. + * + *

A fallback launch is invisible from both ends: the app runs its modules from the snapshot and + * says nothing, and the manager was not running to notice. Neither side can reach the other at the + * moment it happens -- that is the whole problem -- so the host counts the misses in its own storage + * and hands the count over the next time it does reach the manager, as extras on the bind that + * reached it. That is a channel both sides already have, and it costs no change to the framework + * interface the two speak over.

+ */ +class ModuleDeliveryLog { + + private static final String TAG = "LSPatch"; + + static final String EXTRA_FALLBACKS = "fallbackLaunches"; + static final String EXTRA_LAST_FALLBACK_AT = "lastFallbackAt"; + + private static final String PREFS = "lspatch-loader"; + private static final String KEY_FALLBACKS = "fallbackLaunches"; + private static final String KEY_LAST_FALLBACK_AT = "lastFallbackAt"; + + private final SharedPreferences prefs; + + /** What the last bind attempt carried, so only that much is cleared when it lands. */ + private volatile int reported; + + ModuleDeliveryLog(Context context) { + SharedPreferences opened; + try { + opened = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } catch (Throwable t) { + // A host whose storage is not ready yet still has to boot; the count is diagnostics. + Log.w(TAG, "Cannot open the loader's own preferences", t); + opened = null; + } + this.prefs = opened; + } + + /** Puts what has gone unreported onto the bind that is about to be attempted. */ + void describeTo(Intent intent) { + if (prefs == null) return; + int fallbacks = prefs.getInt(KEY_FALLBACKS, 0); + reported = fallbacks; + if (fallbacks <= 0) return; + intent.putExtra(EXTRA_FALLBACKS, fallbacks); + intent.putExtra(EXTRA_LAST_FALLBACK_AT, prefs.getLong(KEY_LAST_FALLBACK_AT, 0L)); + } + + void recordFallback() { + if (prefs == null) return; + prefs.edit() + .putInt(KEY_FALLBACKS, prefs.getInt(KEY_FALLBACKS, 0) + 1) + .putLong(KEY_LAST_FALLBACK_AT, System.currentTimeMillis()) + .apply(); + } + + /** + * Called once the manager has answered -- which is also when it has been handed the count, since + * the extras rode in on that very bind. + * + *

Only what that bind carried is forgotten. This launch may have counted a miss of its own + * after the bind went out and before the manager finally came up, and a count nobody has been + * told is not one to drop.

+ */ + void recordDelivered() { + if (prefs == null) return; + int handedOver = reported; + if (handedOver <= 0) return; + reported = 0; + int remaining = Math.max(0, prefs.getInt(KEY_FALLBACKS, 0) - handedOver); + var edit = prefs.edit().putInt(KEY_FALLBACKS, remaining); + if (remaining == 0) edit.remove(KEY_FALLBACKS).remove(KEY_LAST_FALLBACK_AT); + edit.apply(); + } +} diff --git a/patch-loader/src/main/java/org/lsposed/lspatch/service/ModuleSnapshot.java b/patch-loader/src/main/java/org/lsposed/lspatch/service/ModuleSnapshot.java new file mode 100644 index 000000000..5eeb0f07e --- /dev/null +++ b/patch-loader/src/main/java/org/lsposed/lspatch/service/ModuleSnapshot.java @@ -0,0 +1,205 @@ +package org.lsposed.lspatch.service; + +import android.content.Context; +import android.util.Log; +import com.google.gson.Gson; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import org.lsposed.lspatch.util.LoadedModules; +import org.matrix.vector.ipc.IModuleService; +import org.matrix.vector.ipc.LoadedModule; + +/** + * What the host remembers about the modules the manager last served it. + * + *

A patched app cannot depend on the manager being alive when it starts: the manager is an + * ordinary app, and a device that reaps background processes -- or a person who force-stopped it -- + * leaves the bind unanswered, which used to mean the app started with no modules at all and no way + * to tell. The module list is not secret and barely changes, so the host keeps its own copy and + * loads from that when the manager does not answer in time.

+ * + *

What is recorded is only identity: which module, which APK, and what the manager said + * about it. The code itself is read from the module's installed APK at load time by the same {@link + * LoadedModules#fromApk} the manager-served path uses, so a restored module and a served one are the + * same dex from the same file -- the fallback changes where the list came from, and nothing + * else. What it cannot restore is a change made while the manager was unreachable: a module disabled + * in the manager stays in the snapshot until a launch reaches the manager again, which is the price + * of loading anything at all in that state.

+ */ +class ModuleSnapshot { + + private static final String TAG = "LSPatch"; + + /** Bumped when the shape below changes; an older or newer file is ignored rather than guessed at. */ + private static final int FORMAT = 1; + + private static final Gson GSON = new Gson(); + + /** + * One module, as much of it as survives without the manager. + * + *

{@code sourceDir} and {@code nativeLibraryDir} are carried field by field rather than as a + * parcelled {@link android.content.pm.ApplicationInfo}: a Parcel is a transport, not a storage + * format, and one written by an older platform is not guaranteed to be readable after a system + * update -- exactly the moment the snapshot is most needed.

+ */ + static class Entry { + String packageName; + String apkPath; + int appId; + long versionCode; + boolean legacy; + String sourceDir; + String nativeLibraryDir; + } + + private static class File_ { + int format; + long writtenAt; + List modules; + } + + private final Context context; + private final File file; + + /** Kept in memory so a save can rewrite the whole table after a call that served only half of it. */ + private final Map entries = new LinkedHashMap<>(); + + private boolean loaded; + + ModuleSnapshot(Context context) { + // Not the cache directory: a cleared cache is one of the states in which the manager is least + // likely to be reachable, and the snapshot has to outlive it. No-backup, because it describes + // this device's installed modules and means nothing restored onto another one. + this.context = context; + File dir = new File(context.getNoBackupFilesDir(), "lspatch"); + this.file = new File(dir, "modules.json"); + } + + private synchronized void loadIfNeeded() { + if (loaded) return; + loaded = true; + if (!file.isFile()) return; + try { + var text = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + var parsed = GSON.fromJson(text, File_.class); + if (parsed == null || parsed.format != FORMAT || parsed.modules == null) { + Log.i(TAG, "Ignoring a module snapshot this loader does not understand"); + return; + } + for (var entry : parsed.modules) { + if (entry != null && entry.packageName != null) entries.put(entry.packageName, entry); + } + Log.i(TAG, "Module snapshot holds " + entries.size() + " module(s)"); + } catch (Throwable t) { + Log.w(TAG, "Unreadable module snapshot; ignoring it", t); + entries.clear(); + } + } + + /** + * Replaces everything recorded about modules of one kind with what the manager just served. + * + *

Per kind, because {@code getModules} and {@code getLegacyModules} are two separate calls + * and each is the whole truth about its own half; merging on package name alone would leave a + * legacy module that has since been removed sitting in the table forever.

+ */ + synchronized void save(List served, boolean legacy) { + loadIfNeeded(); + entries.values().removeIf(entry -> entry.legacy == legacy); + for (var module : served) { + if (module == null || module.packageName == null || module.apkPath == null) continue; + var entry = new Entry(); + entry.packageName = module.packageName; + entry.apkPath = module.apkPath; + entry.appId = module.appId; + entry.versionCode = module.versionCode; + entry.legacy = legacy; + if (module.applicationInfo != null) { + entry.sourceDir = module.applicationInfo.sourceDir; + entry.nativeLibraryDir = module.applicationInfo.nativeLibraryDir; + } + entries.put(entry.packageName, entry); + } + write(); + } + + private void write() { + var payload = new File_(); + payload.format = FORMAT; + payload.writtenAt = System.currentTimeMillis(); + payload.modules = new ArrayList<>(entries.values()); + try { + var dir = file.getParentFile(); + if (dir != null) dir.mkdirs(); + // Written beside the target and moved into place, so a process killed mid-write leaves the + // previous snapshot intact rather than a truncated one. + var tmp = new File(file.getPath() + ".tmp"); + Files.write(tmp.toPath(), GSON.toJson(payload).getBytes(StandardCharsets.UTF_8)); + if (!tmp.renameTo(file)) { + Files.deleteIfExists(tmp.toPath()); + Log.w(TAG, "Could not replace the module snapshot"); + } + } catch (Throwable t) { + Log.w(TAG, "Could not write the module snapshot", t); + } + } + + /** + * The modules of one kind, rebuilt from their installed APKs. + * + * @param service what to attach as each module's {@code LoadedModule.service} + */ + synchronized List restore(boolean legacy, Function service) { + loadIfNeeded(); + var modules = new ArrayList(); + for (var entry : entries.values()) { + if (entry.legacy != legacy) continue; + var apkPath = entry.sourceDir != null ? entry.sourceDir : entry.apkPath; + if (apkPath == null || !new File(apkPath).isFile()) { + // Updating a module app moves its APK, so a path recorded before the update points at + // nothing. Ask this process's own PackageManager where the module lives now -- it may + // refuse, because package visibility filters what a patched app is allowed to see, and + // then the module is genuinely unreachable until a launch reaches the manager again. + apkPath = installedApkPath(entry.packageName); + if (apkPath == null) { + Log.w(TAG, "Snapshot module " + entry.packageName + " cannot be found on this device"); + continue; + } + Log.i(TAG, "Snapshot module " + entry.packageName + " has moved to " + apkPath); + } + var info = LoadedModules.syntheticApplicationInfo(entry.packageName, apkPath, entry.nativeLibraryDir); + var module = LoadedModules.fromApk( + entry.packageName, + apkPath, + entry.appId, + entry.versionCode, + info, + legacy, + service.apply(entry.packageName)); + if (module != null) modules.add(module); + } + return modules; + } + + private String installedApkPath(String packageName) { + try { + var path = context.getPackageManager().getApplicationInfo(packageName, 0).sourceDir; + return path != null && new File(path).isFile() ? path : null; + } catch (Throwable t) { + return null; + } + } + + /** Whether anything at all was recorded -- the difference between "degraded" and "nothing to run". */ + synchronized boolean isEmpty() { + loadIfNeeded(); + return entries.isEmpty(); + } +} diff --git a/patch-loader/src/main/java/org/lsposed/lspatch/service/ReconnectingModuleService.java b/patch-loader/src/main/java/org/lsposed/lspatch/service/ReconnectingModuleService.java new file mode 100644 index 000000000..8c8706895 --- /dev/null +++ b/patch-loader/src/main/java/org/lsposed/lspatch/service/ReconnectingModuleService.java @@ -0,0 +1,240 @@ +package org.lsposed.lspatch.service; + +import android.os.Bundle; +import android.os.ParcelFileDescriptor; +import android.util.Log; +import io.github.libxposed.service.IXposedService; +import java.io.File; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.matrix.vector.ipc.IModuleService; +import org.matrix.vector.ipc.IRemotePreferenceCallback; + +/** + * A module's service as the hook sees it, standing in for the manager's own so the hook never holds + * a binder to a process that has gone. + * + *

{@code LoadedModule.service} is handed to the framework once, while the process bootstraps, and + * the framework keeps it for the life of the process. Pointing it straight at the manager therefore + * makes the manager's death permanent for that app: the proxy stays dead even after the manager is + * back, because nothing re-delivers a {@code LoadedModule}. This lives in the host instead and + * forwards to whichever manager binder is current, so a restart is invisible to the module.

+ * + *

Everything here is read-only -- {@link IModuleService} has no writing method, because a hooked + * process runs as the app it was injected into rather than as the module, and writes go through the + * module app's own {@code IXposedService}. That is what makes caching safe: there is no local write + * that could diverge from the store, so a value served from the cache is only ever an older read, + * never a conflicting one.

+ */ +class ReconnectingModuleService extends IModuleService.Stub { + + private static final String TAG = "LSPatch"; + + private final String modulePackageName; + private final File cacheDir; + + private volatile IModuleService live; + + private volatile long properties = IXposedService.PROP_CAP_REMOTE; + private volatile String[] fileNames = new String[0]; + + /** Last value served for a preference group, by group. */ + private final Map> prefsCache = new ConcurrentHashMap<>(); + + /** + * The subscriptions the hook made, kept so they can be made again against a new manager binder. + * A subscription is registered with the manager, so it dies with the manager's process; the hook + * subscribes once and would otherwise never hear another change. + */ + private final Map subscriptions = new ConcurrentHashMap<>(); + + ReconnectingModuleService(String modulePackageName, File stateDir) { + this.modulePackageName = modulePackageName; + this.cacheDir = new File(new File(stateDir, "prefs"), sanitize(modulePackageName)); + } + + /** Points the proxy at the manager binder that is current, or at nothing when it has gone. */ + void setLive(IModuleService live) { + this.live = live; + } + + /** + * Re-establishes what the previous manager process was holding, and tells the hook what changed + * while it was gone. + * + *

The manager only pushes a preference change when the module app writes one, so a hook that + * merely re-subscribes would keep serving whatever it last saw until the next write. Reading each + * subscribed group and delivering the difference is what closes that window; the difference is + * shaped exactly like the editor's own diff, so the hook handles it on the path it already has.

+ */ + void onManagerReconnected(IModuleService live) { + this.live = live; + for (var subscription : subscriptions.entrySet()) { + var group = subscription.getKey(); + try { + var fresh = readMap(live.requestRemotePreferences(group, subscription.getValue())); + var previous = prefsCache.get(group); + store(group, fresh); + var diff = diff(previous, fresh); + if (diff != null) subscription.getValue().onRemotePreferencesChanged(diff); + } catch (Throwable t) { + Log.w(TAG, "Could not restore the subscription to " + modulePackageName + "/" + group, t); + } + } + } + + @Override + public long getFrameworkProperties() { + var live = this.live; + if (live == null) return properties; + try { + properties = live.getFrameworkProperties(); + } catch (Throwable t) { + Log.w(TAG, "getFrameworkProperties fell back to the last known value", t); + } + return properties; + } + + @Override + public Bundle requestRemotePreferences(String group, IRemotePreferenceCallback callback) { + if (callback != null) subscriptions.put(group, callback); + var live = this.live; + if (live != null) { + try { + var values = readMap(live.requestRemotePreferences(group, callback)); + store(group, values); + return bundle(values); + } catch (Throwable t) { + Log.w(TAG, "Reading " + modulePackageName + "/" + group + " from the manager failed", t); + } + } + return bundle(cached(group)); + } + + @Override + public ParcelFileDescriptor openRemoteFile(String path) { + var live = this.live; + if (live == null) { + // No local copy of the module's remote files is kept, so with the manager gone the file is + // simply not there -- which is the case the API already documents a null return for. + Log.d(TAG, "Remote file " + path + " is unavailable while the manager is unreachable"); + return null; + } + try { + return live.openRemoteFile(path); + } catch (Throwable t) { + Log.w(TAG, "openRemoteFile " + path + " failed", t); + return null; + } + } + + @Override + public String[] getRemoteFileNames() { + var live = this.live; + if (live != null) { + try { + var names = live.getRemoteFileNames(); + if (names != null) fileNames = names; + } catch (Throwable t) { + Log.w(TAG, "getRemoteFileNames fell back to the last known list", t); + } + } + return fileNames; + } + + private static Bundle bundle(HashMap values) { + var bundle = new Bundle(); + bundle.putSerializable("map", values); + return bundle; + } + + @SuppressWarnings("unchecked") + private static HashMap readMap(Bundle bundle) { + if (bundle == null) return new HashMap<>(); + var map = bundle.getSerializable("map"); + return map instanceof HashMap ? (HashMap) map : new HashMap<>(); + } + + /** + * The diff between two reads, in the shape {@code RemotePreferences.Editor} produces, or null + * when nothing changed. + */ + private static Bundle diff(Map before, Map after) { + if (before == null) return null; + var removed = new HashSet(); + for (var key : before.keySet()) { + if (!after.containsKey(key)) removed.add(key); + } + var changed = new HashMap(); + for (var entry : after.entrySet()) { + var old = before.get(entry.getKey()); + if (old == null || !old.equals(entry.getValue())) changed.put(entry.getKey(), entry.getValue()); + } + if (removed.isEmpty() && changed.isEmpty()) return null; + var diff = new Bundle(); + if (!removed.isEmpty()) diff.putSerializable("delete", removed); + if (!changed.isEmpty()) diff.putSerializable("put", changed); + return diff; + } + + private HashMap cached(String group) { + var values = prefsCache.get(group); + if (values != null) return values; + values = readFromDisk(group); + prefsCache.put(group, values); + return values; + } + + private void store(String group, HashMap values) { + var previous = prefsCache.put(group, values); + if (values.equals(previous)) return; + writeToDisk(group, values); + } + + /** + * The cache outlives the process, because the launch that needs it most is the one where the app + * starts cold and the manager never comes up at all. + * + *

Java serialization, as in the store this mirrors. Only values that survived the manager's own + * deserialization ever reach here, and the manager can no more load a module-defined class than + * this process can -- so what is written is always platform types the host can read back.

+ */ + private HashMap readFromDisk(String group) { + var file = new File(cacheDir, sanitize(group) + ".prefs"); + if (!file.isFile()) return new HashMap<>(); + try (var in = new ObjectInputStream(Files.newInputStream(file.toPath()))) { + var read = in.readObject(); + if (read instanceof HashMap) { + //noinspection unchecked + return (HashMap) read; + } + } catch (Throwable t) { + Log.w(TAG, "Unreadable preference cache for " + modulePackageName + "/" + group, t); + } + return new HashMap<>(); + } + + private void writeToDisk(String group, HashMap values) { + var file = new File(cacheDir, sanitize(group) + ".prefs"); + try { + cacheDir.mkdirs(); + var tmp = new File(file.getPath() + ".tmp"); + try (var out = new ObjectOutputStream(Files.newOutputStream(tmp.toPath()))) { + out.writeObject(values); + } + if (!tmp.renameTo(file)) Files.deleteIfExists(tmp.toPath()); + } catch (Throwable t) { + Log.w(TAG, "Could not cache " + modulePackageName + "/" + group, t); + } + } + + /** A group or package name is free-form; a file name is not. */ + private static String sanitize(String name) { + return name.replaceAll("[^A-Za-z0-9_.-]", "_") + "-" + Integer.toHexString(name.hashCode()); + } +} diff --git a/patch-loader/src/main/java/org/lsposed/lspatch/service/RemoteApplicationService.java b/patch-loader/src/main/java/org/lsposed/lspatch/service/RemoteApplicationService.java index 19bcaf137..542f11af1 100644 --- a/patch-loader/src/main/java/org/lsposed/lspatch/service/RemoteApplicationService.java +++ b/patch-loader/src/main/java/org/lsposed/lspatch/service/RemoteApplicationService.java @@ -5,6 +5,7 @@ import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; +import android.os.Binder; import android.os.Build; import android.os.Environment; import android.os.Handler; @@ -16,124 +17,349 @@ import android.os.UserHandle; import android.util.Log; import android.widget.Toast; - -import org.lsposed.lspatch.share.Constants; -import org.matrix.vector.ipc.IFrameworkService; -import org.matrix.vector.ipc.IProcessChannel; -import org.matrix.vector.ipc.LoadedModule; - import java.io.File; -import java.lang.reflect.InvocationTargetException; -import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import org.lsposed.lspatch.share.Constants; +import org.lsposed.lspatch.util.LoadedModules; +import org.matrix.vector.ipc.IFrameworkService; +import org.matrix.vector.ipc.IProcessChannel; +import org.matrix.vector.ipc.LoadedModule; /** * The {@link IFrameworkService} for manager mode: it binds the manager's service and forwards module * queries to it, so the app is served whatever modules the manager has scoped to it. + * + *

The manager is an ordinary app and can be gone at any moment -- reaped for memory, force-stopped + * by a person or by whatever the device calls its battery saver. Three things follow, and this class + * is where all three are handled. The binding is kept and re-established rather than made once, so a + * manager that dies mid-session comes back on its own. Every module's service is handed to the + * framework through a {@link ReconnectingModuleService}, so the module never holds a binder into a + * process that has gone. And when the manager does not answer at startup at all, the modules are + * loaded from {@link ModuleSnapshot} -- the same APKs, listed from the host's own copy -- rather than + * the app starting silently unhooked.

*/ public class RemoteApplicationService implements IFrameworkService { private static final String TAG = "LSPatch"; /** - * How long the app waits for the manager's binder. + * How long the app waits for the manager's binder before starting without it. * - * The bind carries BIND_AUTO_CREATE, so when the manager is not running this wait covers starting - * its process from nothing before it can answer. The app's own startup is held open meanwhile, - * which is why it is a few seconds and not more. + * The bind carries BIND_AUTO_CREATE, so this covers starting the manager's process from nothing. + * The app's own startup is held open meanwhile, which is why it is short: a miss is no longer + * fatal -- the snapshot answers instead and the binding stays live for whenever the manager does + * come up -- so there is nothing to buy by waiting longer. */ - private static final long BIND_TIMEOUT_MS = 5000; + private static final long BIND_TIMEOUT_MS = 1500; + + private static final long REBIND_DELAY_MS = 2000; + private static final long REBIND_MAX_DELAY_MS = 300_000; + + private final Context context; + private final String managerPackage; + private final ModuleSnapshot snapshot; + private final ModuleDeliveryLog deliveryLog; + private final File stateDir; + + private final ScheduledExecutorService worker = + Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "lspatch-manager-link")); + + /** One per module, for the life of the process; what the framework is handed. */ + private final Map moduleServices = new ConcurrentHashMap<>(); + + /** + * This service's own identity, which is the host's and not the manager's. + * + * The framework keeps a service only if it can take a binder from it and watch that binder die + * ({@code VectorServiceClient.init}), so answering with the manager's binder would mean answering + * with null whenever the manager is away -- and being dropped for the life of the process at the + * exact moment this class exists to cover. It is a local binder: it never dies, the framework's + * death watch is therefore a no-op, and the manager's comings and goings are handled here instead + * of ending the framework's client. + */ + private final Binder token = new Binder(); + + /** Only ever touched through {@link #legacyHandler()}; null on Q and later, where it is never needed. */ + private Handler legacyHandler; private volatile IFrameworkService service; + private volatile boolean bound; + private volatile boolean everConnected; + private volatile long rebindDelay = REBIND_DELAY_MS; - @SuppressLint("DiscouragedPrivateApi") - public RemoteApplicationService(Context context, String managerPackageName) throws RemoteException { - var packageName = (managerPackageName == null || managerPackageName.isEmpty()) + /** + * The channel the manager drives hot reload over. Created when the framework attaches its own and + * kept, because the manager loses its side with its process and has to be handed one again. + */ + private volatile LSPatchProcessChannel processChannel; + + private final ServiceConnection connection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + var manager = IFrameworkService.Stub.asInterface(binder); + service = manager; + rebindDelay = REBIND_DELAY_MS; + deliveryLog.recordDelivered(); + if (!everConnected) { + everConnected = true; + Log.i(TAG, "Manager binder received"); + connected.countDown(); + return; + } + // A reconnection: the framework asked for its modules long ago and will not ask again, so + // everything the previous manager process was holding has to be re-established from here. + Log.i(TAG, "Manager is back; restoring what it was holding"); + worker.execute(() -> reestablish(manager)); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + // The binding survives: with BIND_AUTO_CREATE the system restarts the manager and calls + // back here on its own, and until it does the module services serve what they cached. + Log.w(TAG, "Manager service died"); + service = null; + } + + @Override + public void onBindingDied(ComponentName name) { + // Permanent, unlike a death: the package was replaced or force-stopped, and nothing is + // coming back on this binding. Only an explicit rebind reaches the manager again. + Log.w(TAG, "Binding to the manager died; will rebind"); + service = null; + unbind(); + scheduleRebind(); + } + + @Override + public void onNullBinding(ComponentName name) { + Log.e(TAG, "Manager refused to serve this app"); + service = null; + unbind(); + scheduleRebind(); + } + }; + + private final CountDownLatch connected = new CountDownLatch(1); + + public RemoteApplicationService(Context context, String managerPackageName) { + this.context = context; + this.managerPackage = (managerPackageName == null || managerPackageName.isEmpty()) ? Constants.MANAGER_PACKAGE_NAME : managerPackageName; - try { - var intent = new Intent() - .setComponent(new ComponentName(packageName, Constants.MANAGER_SERVICE_NAME)) - .putExtra("packageName", context.getPackageName()); - // TODO: Authentication - var latch = new CountDownLatch(1); - var conn = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder binder) { - Log.i(TAG, "Manager binder received"); - service = IFrameworkService.Stub.asInterface(binder); - latch.countDown(); - } + this.stateDir = new File(context.getNoBackupFilesDir(), "lspatch"); + this.snapshot = new ModuleSnapshot(context); + this.deliveryLog = new ModuleDeliveryLog(context); - @Override - public void onServiceDisconnected(ComponentName name) { - Log.e(TAG, "Manager service died"); - service = null; + Log.i(TAG, "Request manager binder from " + managerPackage); + var start = SystemClock.elapsedRealtime(); + if (bind()) { + try { + if (connected.await(BIND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + Log.i(TAG, "Manager binder received in " + (SystemClock.elapsedRealtime() - start) + "ms"); + return; } - }; - Log.i(TAG, "Request manager binder from " + packageName); - boolean bound; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + // A late bind and one that never lands are the same from here, and only the elapsed time + // tells them apart. The binding is left in place: the manager may still come up, and when + // it does it corrects the snapshot for the next launch. + Log.w(TAG, "Manager did not answer in " + (SystemClock.elapsedRealtime() - start) + "ms"); + } else { + // The system refuses when it will not start the manager at all -- it is not installed, or + // its package is in a state the system will not launch from here. Waiting changes nothing, + // so this is reported on its own and the rebind is left to the schedule. + Log.e(TAG, "System refused to bind " + managerPackage + "; it may not be installed"); + scheduleRebind(); + } + deliveryLog.recordFallback(); + if (snapshot.isEmpty()) { + // Nothing cached and nobody to ask: this app is genuinely running unhooked, and that is + // worth telling the person holding the phone, because nothing else will. + toast("LSPatch manager not reachable"); + } else { + Log.i(TAG, "Loading modules from this app's own snapshot"); + } + } + + @SuppressLint("DiscouragedPrivateApi") + private boolean bind() { + var intent = new Intent() + .setComponent(new ComponentName(managerPackage, Constants.MANAGER_SERVICE_NAME)) + .putExtra("packageName", context.getPackageName()); + // TODO: Authentication + deliveryLog.describeTo(intent); + try { + boolean ok; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - bound = context.bindService( - intent, Context.BIND_AUTO_CREATE, Executors.newSingleThreadExecutor(), conn); + ok = context.bindService(intent, Context.BIND_AUTO_CREATE, worker, connection); } else { - var handlerThread = new HandlerThread("RemoteApplicationService"); - handlerThread.start(); - var handler = new Handler(handlerThread.getLooper()); var contextImplClass = context.getClass(); var getUserMethod = contextImplClass.getMethod("getUser"); var bindServiceAsUserMethod = contextImplClass.getDeclaredMethod( - "bindServiceAsUser", Intent.class, ServiceConnection.class, int.class, Handler.class, UserHandle.class); + "bindServiceAsUser", + Intent.class, + ServiceConnection.class, + int.class, + Handler.class, + UserHandle.class); var userHandle = (UserHandle) getUserMethod.invoke(context); - bound = Boolean.TRUE.equals(bindServiceAsUserMethod.invoke( - context, intent, conn, Context.BIND_AUTO_CREATE, handler, userHandle)); + ok = Boolean.TRUE.equals(bindServiceAsUserMethod.invoke( + context, intent, connection, Context.BIND_AUTO_CREATE, legacyHandler(), userHandle)); } - // A refusal and a slow start are different failures. The system refuses when it will not - // start the manager at all -- it is not installed, or its package is in a state the system - // will not launch -- and no amount of waiting changes that, so it is reported at once and - // on its own. - if (!bound) { - Log.e(TAG, "System refused to bind " + packageName + "; it may not be installed"); - Toast.makeText(context, "LSPatch manager not reachable", Toast.LENGTH_SHORT).show(); - throw new RemoteException("bindService refused for " + packageName); + bound = ok; + return ok; + } catch (Throwable t) { + Log.e(TAG, "Cannot bind the manager", t); + bound = false; + return false; + } + } + + /** + * The thread the pre-Q bind path delivers its callbacks on, made once. + * + * That path takes a Handler rather than an Executor, and the binding is now re-established as often as the manager + * comes and goes -- so a thread built per attempt would be one more looper left running for every rebind on a + * device old enough to need this path at all. + */ + private synchronized Handler legacyHandler() { + if (legacyHandler == null) { + var thread = new HandlerThread("lspatch-manager-link-legacy"); + thread.start(); + legacyHandler = new Handler(thread.getLooper()); + } + return legacyHandler; + } + + private void unbind() { + if (!bound) return; + bound = false; + try { + context.unbindService(connection); + } catch (Throwable t) { + Log.w(TAG, "Cannot release the manager binding", t); + } + } + + private void scheduleRebind() { + var delay = rebindDelay; + rebindDelay = Math.min(rebindDelay * 2, REBIND_MAX_DELAY_MS); + worker.schedule( + () -> { + if (service != null) return; + Log.i(TAG, "Rebinding the manager"); + if (!bind()) scheduleRebind(); + }, + delay, + TimeUnit.MILLISECONDS); + } + + /** + * Hands a manager that has just come back everything the previous one was holding: the channel it + * drives hot reload over, which modules this process is running, and a live binder for each of the + * module services the hook is still using. + * + *

The module lists are asked for again because that request is what carries all three: the + * manager records the caller's modules and pushes each module's service to its companion app while + * answering it, and the answer is where a fresh {@code IModuleService} per module comes from. The + * code it maps for that answer is nobody's to load -- the modules in this process were loaded long + * ago -- so it is released rather than left to a finalizer.

+ */ + private void reestablish(IFrameworkService manager) { + var channel = processChannel; + if (channel != null) { + try { + manager.attachProcessChannel(channel); + } catch (Throwable t) { + Log.w(TAG, "Could not re-attach the process channel", t); + } + } + for (boolean legacy : new boolean[] {true, false}) { + List served; + try { + served = legacy ? manager.getLegacyModules() : manager.getModules(); + } catch (Throwable t) { + Log.w(TAG, "Could not re-read the module list", t); + continue; } - var start = SystemClock.elapsedRealtime(); - boolean success = latch.await(BIND_TIMEOUT_MS, TimeUnit.MILLISECONDS); - var elapsed = SystemClock.elapsedRealtime() - start; - if (!success) { - // The app's own start is held open by this wait, so it ends rather than growing. The - // elapsed time is logged either way: a late bind and one that never lands are the same - // from here, and only that number tells them apart. - Log.e(TAG, "Manager did not answer in " + elapsed + "ms"); - Toast.makeText(context, "LSPatch manager did not answer", Toast.LENGTH_SHORT).show(); - throw new RemoteException("No manager binder after " + elapsed + "ms"); + if (served == null) continue; + for (var module : served) { + if (module == null || module.packageName == null) continue; + var proxy = moduleServices.get(module.packageName); + if (proxy != null) proxy.onManagerReconnected(module.service); + LoadedModules.discard(module); } - Log.i(TAG, "Manager binder received in " + elapsed + "ms"); - } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | - InterruptedException e) { - Toast.makeText(context, "Unable to connect to Manager", Toast.LENGTH_SHORT).show(); - var r = new RemoteException("Failed to get manager binder"); - r.initCause(e); - throw r; + snapshot.save(served, legacy); + } + } + + /** Replaces each module's manager binder with the host-side proxy the framework will keep. */ + private void adopt(List served) { + for (var module : served) { + if (module == null || module.packageName == null) continue; + var proxy = moduleService(module.packageName); + proxy.setLive(module.service); + module.service = proxy; + } + } + + private ReconnectingModuleService moduleService(String modulePackageName) { + return moduleServices.computeIfAbsent(modulePackageName, pkg -> new ReconnectingModuleService(pkg, stateDir)); + } + + private List modules(boolean legacy) { + var manager = service; + if (manager != null) { + try { + var served = legacy ? manager.getLegacyModules() : manager.getModules(); + if (served != null) { + adopt(served); + // Off this thread: the app's own start is waiting on this call, and recording what + // was served is for the next launch rather than for this one. + worker.execute(() -> snapshot.save(served, legacy)); + return served; + } + } catch (Throwable t) { + Log.w(TAG, "The manager could not serve this app's modules", t); + } + } + var restored = snapshot.restore(legacy, this::moduleService); + // Said out loud, because this is the one path where nobody else can say it: the manager did not + // answer, so the count of what was loaded anyway exists only here. + Log.i(TAG, "Serving " + restored.size() + (legacy ? " legacy" : "") + " module(s) from the snapshot"); + return restored; + } + + private void toast(String message) { + try { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show(); + } catch (Throwable t) { + // No looper on this thread, or a context that cannot show one: the log line is the report. + Log.w(TAG, message); } } @Override public boolean isLogMuted() throws RemoteException { - return service != null && service.isLogMuted(); + var manager = service; + return manager != null && manager.isLogMuted(); } @Override - public List getLegacyModules() throws RemoteException { - return service == null ? new ArrayList<>() : service.getLegacyModules(); + public List getLegacyModules() { + return modules(true); } @Override - public List getModules() throws RemoteException { - return service == null ? new ArrayList<>() : service.getModules(); + public List getModules() { + return modules(false); } @Override @@ -143,7 +369,8 @@ public String getPrefsPath(String packageName) { @Override public ParcelFileDescriptor openManagerApk() throws RemoteException { - return service == null ? null : service.openManagerApk(); + var manager = service; + return manager == null ? null : manager.openManagerApk(); } @Override @@ -152,15 +379,23 @@ public IBinder requestManagerService() { } @Override - public void attachProcessChannel(IProcessChannel channel) throws RemoteException { + public void attachProcessChannel(IProcessChannel channel) { // The manager drives hot reload but is a plain app, so the framework's own channel -- which // gates on the system uid -- would refuse it. Hand the manager an LSPatch channel that runs the // in-process swap for it instead; the framework's channel is unused without a daemon. - if (service != null) service.attachProcessChannel(new LSPatchProcessChannel()); + var ours = new LSPatchProcessChannel(); + processChannel = ours; + var manager = service; + if (manager == null) return; + try { + manager.attachProcessChannel(ours); + } catch (Throwable t) { + Log.w(TAG, "Could not attach the process channel", t); + } } @Override public IBinder asBinder() { - return service == null ? null : service.asBinder(); + return token; } } diff --git a/share/android/src/main/java/org/lsposed/lspatch/util/LoadedModules.java b/share/android/src/main/java/org/lsposed/lspatch/util/LoadedModules.java new file mode 100644 index 000000000..5cd61b889 --- /dev/null +++ b/share/android/src/main/java/org/lsposed/lspatch/util/LoadedModules.java @@ -0,0 +1,97 @@ +package org.lsposed.lspatch.util; + +import android.content.pm.ApplicationInfo; +import android.util.Log; +import org.matrix.vector.ipc.IModuleService; +import org.matrix.vector.ipc.LoadedModule; + +/** + * Builds the {@link LoadedModule} a framework hands an injected process, out of a module APK on disk. + * + *

Every mode reaches a module the same way in the end -- read the APK with {@link ModuleLoader}, + * describe who the module is, attach the service its remote calls travel over -- and only the three + * inputs differ: an embedded module is extracted from the host's own assets and has no installed + * identity, a manager-served module is an installed app PackageManager can describe, and a module + * restored from a host's snapshot is an installed app described by what the snapshot recorded. This + * is that one shared step, so the three callers differ in what they pass rather than in what they + * build.

+ */ +public final class LoadedModules { + + private static final String TAG = "LSPatch"; + + private LoadedModules() {} + + /** + * Describes a module that {@link android.content.pm.PackageManager} cannot. + * + *

An embedded module is not installed as an app at all, and a process restoring its modules + * from a snapshot may not be allowed to see the module's package (package visibility filters a + * patched app's query for anything it does not declare). The framework only reads the package + * name, the APK location and the native library directory off this, so carrying those is enough.

+ */ + public static ApplicationInfo syntheticApplicationInfo( + String packageName, String apkPath, String nativeLibraryDir) { + ApplicationInfo info = new ApplicationInfo(); + info.packageName = packageName; + info.sourceDir = apkPath; + info.publicSourceDir = apkPath; + info.nativeLibraryDir = nativeLibraryDir; + return info; + } + + /** + * Reads {@code apkPath} into a module ready to be served, or null when it is not loadable -- or + * not of the requested kind. + * + * @param requireLegacy null to accept the module whatever it is; true or false to accept only a + * legacy or only a modern one. A module of the other kind has the dexes just + * mapped for it closed here rather than left to a finalizer, because the two + * kinds are served by separate calls and the caller of one never sees the + * other's memory. + */ + public static LoadedModule fromApk( + String packageName, + String apkPath, + int appId, + long versionCode, + ApplicationInfo applicationInfo, + Boolean requireLegacy, + IModuleService service) { + var code = ModuleLoader.loadModule(apkPath); + if (code == null) { + Log.w(TAG, "Failed to load module " + packageName + " from " + apkPath); + return null; + } + if (requireLegacy != null && code.legacy != requireLegacy) { + code.preLoadedDexes.forEach(dex -> { + try { + dex.close(); + } catch (Throwable ignored) { + } + }); + return null; + } + var module = new LoadedModule(); + module.packageName = packageName; + module.apkPath = apkPath; + module.appId = appId; + module.versionCode = versionCode; + module.code = code; + module.applicationInfo = + applicationInfo != null ? applicationInfo : syntheticApplicationInfo(packageName, apkPath, null); + module.service = service; + return module; + } + + /** Releases the shared memory of a module nobody is going to load. */ + public static void discard(LoadedModule module) { + if (module == null || module.code == null || module.code.preLoadedDexes == null) return; + for (var dex : module.code.preLoadedDexes) { + try { + dex.close(); + } catch (Throwable ignored) { + } + } + } +}