Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions manager/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Copy>("build$variantCapped") {
dependsOn(tasks["assemble$variantCapped"])
Expand Down
36 changes: 30 additions & 6 deletions manager/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@
<!-- Module Store fetches the LSPosed modules.json catalog. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Continuous log monitoring runs as a foreground service so the process (which holds the
Shizuku binding) is not reaped in the background. Log collection maps to no standard
foreground-service bucket, so it declares the "special use" type. -->
<!-- The manager runs a foreground service so its process is not reaped in the background: a
patched app reaches it the moment it starts, and a reaped manager answers late or not at all.
Neither that nor log collection maps to a standard foreground-service bucket, so it declares
the "special use" type. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- So the service comes back after a reboot without the person having to open the manager. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Only ever used to ask, through the system's own dialog. Doze is what stops a manager that was
never opened today from answering a patched app at all. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />

<application
android:name=".LSPApplication"
Expand Down Expand Up @@ -50,15 +56,33 @@
android:name=".manager.ModuleService"
android:exported="true" />

<!-- Exported so the Shizuku shell can start it: that watchdog runs as the shell user, which is
the whole reason it survives what stops this app, and the shell may only start a component
that says it may. It grants nothing new — any app can already cause this manager to run by
binding the exported ModuleService below, and all this one does is make it present. -->
<service
android:name=".service.LogCollectorService"
android:exported="false"
android:name=".service.ManagerResidentService"
android:exported="true"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="continuous log monitoring for patched apps and the Xposed framework" />
android:value="serving modules to patched apps and monitoring their logs" />
</service>

<!-- Brings the service back after a reboot or an update of the manager itself. A package the
system considers stopped receives neither, which is exactly the state a force-stop leaves
it in: this covers the reboot, and the shell-side watchdog covers the rest.
Exported because the sender is the system rather than this app; both actions are
protected broadcasts, so nothing else can send them. -->
<receiver
android:name=".manager.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>

<!-- Hands one patched apk to whatever installer the device has, when neither the shell nor
the platform session could install it. A patched apk lives in this app's private
storage, so it can only travel as a granted content uri rather than as a path. -->
Expand Down
16 changes: 16 additions & 0 deletions manager/src/main/aidl/org/lsposed/lspatch/IShizukuService.aidl
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
9 changes: 5 additions & 4 deletions manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
108 changes: 108 additions & 0 deletions manager/src/main/java/org/lsposed/lspatch/ShizukuService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<package>: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 `<prefix>_<timestamp>.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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading