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
38 changes: 24 additions & 14 deletions manager/src/main/java/org/lsposed/lspatch/Patcher.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.lsposed.lspatch

import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.lsposed.lspatch.config.Configs
Expand All @@ -11,16 +12,15 @@ import org.lsposed.patch.KeystoreSpec
import org.lsposed.patch.ManifestOverrides
import org.lsposed.patch.PatchSpec
import org.lsposed.patch.util.Logger
import java.io.File

object Patcher {

/**
* Translates a [PatchRequest] into the patcher's own spec.
*
* Built directly rather than rendered into command-line flags for the patcher to parse back:
* every value here is already typed, and the round trip through argv was only ever an artefact
* of the engine and the CLI having been the same class.
* Built directly rather than rendered into command-line flags for the patcher to parse back: every value here is
* already typed, and the round trip through argv was only ever an artefact of the engine and the CLI having been
* the same class.
*/
private fun PatchRequest.toSpec(outputDir: File): PatchSpec =
PatchSpec.builder()
Expand Down Expand Up @@ -51,25 +51,35 @@ object Patcher {
)
.keystore(
if (MyKeyStore.useDefault) KeystoreSpec.builtIn()
else KeystoreSpec.of(
MyKeyStore.file,
Configs.keyStorePassword,
Configs.keyStoreAlias,
Configs.keyStoreAliasPassword,
)
else
KeystoreSpec.of(
MyKeyStore.file,
Configs.keyStorePassword,
Configs.keyStoreAlias,
Configs.keyStoreAliasPassword,
)
)
.build()

/**
* Runs [request] and returns the apks it produced.
*
* The result stays where it was written -- app-private, one directory per package. It used to be
* copied on to a folder the user had picked through the storage access framework, which meant
* every patch depended on a persisted grant; the entry point that never asked for one therefore
* failed at this exact point, every time.
* The result stays where it was written -- app-private, one directory per package. It used to be copied on to a
* folder the user had picked through the storage access framework, which meant every patch depended on a persisted
* grant; the entry point that never asked for one therefore failed at this exact point, every time.
*/
suspend fun patch(logger: Logger, request: PatchRequest): List<File> =
withContext(Dispatchers.IO) {
// Checked here rather than left to the engine: by this point the paths are the ones the
// system reports for the target now, so their absence is a fact about the app and can be
// said as one, instead of surfacing as the engine's own exception type and message.
val missing = request.target.apkPaths.filterNot { File(it).exists() }
if (missing.isNotEmpty()) {
throw java.io.IOException(
"${request.label} has no apk at ${missing.first()}" +
if (missing.size > 1) " (and ${missing.size - 1} more)" else ""
)
}
val outputDir = PatchOutputStore.prepare(request.packageName)
val produced = ApkPatcher(logger, request.toSpec(outputDir)).patch()
if (produced.isEmpty()) throw java.io.IOException("The patcher produced no apk")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.lsposed.lspatch.data.model.LogLine
import org.lsposed.lspatch.data.model.PatchRequest
import org.lsposed.lspatch.data.model.PatchStage
import org.lsposed.lspatch.data.model.PatchStep
import org.lsposed.lspatch.data.model.PatchTarget
import org.lsposed.lspatch.lspApp
import org.lsposed.lspatch.util.LSPPackageManager
import org.lsposed.lspatch.util.ShizukuApi
Expand Down Expand Up @@ -124,12 +125,13 @@ object PatchJobHost {
* A second request is refused rather than queued: two patches at once would contend for the package installer, and
* a queue would leave the user watching a job they did not ask for yet.
*/
fun start(request: PatchRequest): Boolean {
fun start(requested: PatchRequest): Boolean {
if (busy) return false
val (request, note) = resolvedNow(requested)
_log.value = emptyList()
_active.value = request
_step.value = PatchStep.Preparing(request)
appendHeader(PatchReport.preamble(request))
appendHeader(PatchReport.preamble(request, note))
currentStage = null
startedAt = System.currentTimeMillis()
job =
Expand All @@ -154,6 +156,35 @@ object PatchJobHost {
return true
}

/**
* [request] with an installed target's apks as the system reports them now, and a note when that differs from what
* the request carries.
*
* A request records where its target was when it was built, then survives editing, storage and a re-run, so the
* moment it is built and the moment it is used are not the same one -- and between them an update, a reinstall or
* an uninstall moves or removes exactly those files. The recorded paths are therefore treated as a name for the app
* rather than as its location, and the location is read again here. Apks picked from storage are copies this app
* owns, and nothing outside it can move them.
*/
private fun resolvedNow(request: PatchRequest): Pair<PatchRequest, String?> {
val target = request.target as? PatchTarget.InstalledApp ?: return request to null
val live = LSPPackageManager.installedApkPaths(target.packageName)
if (live == null) {
// Absent and absent-with-a-record are different answers to "what happened to it", and a
// report that cannot separate them leaves the question open.
val kept = LSPPackageManager.isArchivedPackage(target.packageName)
val note =
if (kept) "${target.packageName} is archived: the device keeps the package, not its apks"
else "${target.packageName} is not installed; the recorded paths are used unchanged"
Log.w(TAG, note)
return request to note
}
if (live == target.apkPaths) return request to null
Log.i(TAG, "${target.packageName} moved: recorded ${target.apkPaths}, now $live")
return request.copy(target = target.copy(apkPaths = live)) to
"read again: ${target.packageName} has moved since this request was built"
}

/**
* Installs what the current [PatchStep.Patched] produced.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ object PatchReport {
private val wallClock = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.ROOT)

/** The environment and the request, as the opening lines of a job's log. */
fun preamble(request: PatchRequest): List<String> = buildList {
fun preamble(request: PatchRequest, inputNote: String? = null): List<String> = buildList {
add("=== LSPatch patch report ===")
add("Started ${wallClock.format(Date())}")
add("")
Expand Down Expand Up @@ -64,6 +64,7 @@ object PatchReport {
add("Verbose ${Configs.detailPatchLogs}")
add("")
add("-- Input apks (${request.target.apkPaths.size}) --")
inputNote?.let { add(" ($it)") }
request.target.apkPaths.forEach { path ->
val file = File(path)
add(" ${file.name} ${sizeOf(file)}${if (file.exists()) "" else " (MISSING)"}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import android.content.pm.PackageInstallerHidden.SessionParamsHidden
import android.content.pm.PackageManager
import android.content.pm.PackageManagerHidden
import android.net.Uri
import android.os.Build
import android.os.Parcelable
import android.util.Log
import androidx.compose.runtime.getValue
Expand Down Expand Up @@ -292,6 +293,35 @@ object LSPPackageManager {
)
}

/**
* Where [packageName] is installed right now, base first -- or null when the package manager does not list it.
*
* Every install of a package writes its apks into a freshly named directory, so a path read once describes where
* the app was at that moment, not where it is. Anything holding on to such a path has to ask again before using it.
*/
fun installedApkPaths(packageName: String): List<String>? = runCatching {
val app = lspApp.packageManager.getApplicationInfo(packageName, 0)
listOf(app.sourceDir) + (app.splitSourceDirs ?: emptyArray())
}
.getOrNull()

/**
* Whether [packageName] is on the device as a record without its apks.
*
* A package can be kept while its files are removed, which leaves it out of an ordinary lookup and yet not
* uninstalled. The distinction is invisible from a missing path alone, and is the difference between an app that
* can be restored by opening it and one that is gone.
*/
fun isArchivedPackage(packageName: String): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM &&
runCatching {
lspApp.packageManager.getPackageInfo(
packageName,
PackageManager.PackageInfoFlags.of(PackageManager.MATCH_ARCHIVED_PACKAGES),
)
}
.isSuccess

suspend fun cleanTmpApkDir() {
withContext(Dispatchers.IO) {
lspApp.tmpApkDir.listFiles()?.forEach(File::delete)
Expand Down
Loading