diff --git a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt index 8f9251554..91eaaab68 100644 --- a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt +++ b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt @@ -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 @@ -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() @@ -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 = 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") diff --git a/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchJobHost.kt b/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchJobHost.kt index b632eec28..d8d35d4d3 100644 --- a/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchJobHost.kt +++ b/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchJobHost.kt @@ -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 @@ -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 = @@ -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 { + 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. * diff --git a/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt b/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt index 5ec645f8d..b07730d9a 100644 --- a/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt +++ b/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt @@ -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 = buildList { + fun preamble(request: PatchRequest, inputNote: String? = null): List = buildList { add("=== LSPatch patch report ===") add("Started ${wallClock.format(Date())}") add("") @@ -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)"}") diff --git a/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt b/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt index 80801c106..58517d84e 100644 --- a/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt +++ b/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt @@ -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 @@ -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? = 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)