From f1d72174956c42234664dce152a27e6854e347e2 Mon Sep 17 00:00:00 2001 From: oSumAtrIX Date: Sun, 11 Feb 2024 21:09:38 +0100 Subject: [PATCH] feat: Read and write arbitrary files in APK files This commit allows reading and writing arbitrary files in an APK file. Additionally it allows deleting files from APK files. A `RawResourcePatch` class has been added which has access to `ResourceContext` but ReVanced Patcher will not decode APK resources. A regular `ResourcePatch` can read and write arbitrary files from an APK file, unless they are decoded to `PatcherConfig.apkFiles`. On attempt to get a file from `PatcherConfig.apkFiles` if the second parameter is true, it will read and write the raw resource file from the original APK to `PatcherConfig.apkFiles` if it does not exist. With this commit, many APIs have been deprecated as well, such as `DomFileEditor` and instead a `Document` has been added. --- .editorconfig | 3 + api/revanced-patcher.api | 112 ++++++- build.gradle.kts | 8 +- gradle.properties | 1 - gradle/libs.versions.toml | 12 +- .../revanced/patcher/IntegrationsConsumer.kt | 3 + .../app/revanced/patcher/InternalApi.kt | 7 + .../app/revanced/patcher/PackageMetadata.kt | 2 + .../kotlin/app/revanced/patcher/Patcher.kt | 94 +++--- .../app/revanced/patcher/PatcherConfig.kt | 72 +++++ .../app/revanced/patcher/PatcherContext.kt | 21 +- .../app/revanced/patcher/PatcherOptions.kt | 53 +--- .../app/revanced/patcher/PatcherResult.kt | 115 ++++++- .../revanced/patcher/data/BytecodeContext.kt | 282 +++++++++--------- .../revanced/patcher/data/ResourceContext.kt | 274 +++++++++++------ .../revanced/patcher/patch/BytecodePatch.kt | 4 +- .../app/revanced/patcher/patch/Patch.kt | 4 +- .../patcher/patch/RawResourcePatch.kt | 43 +++ .../revanced/patcher/patch/ResourcePatch.kt | 9 +- .../app/revanced/patcher/util/Document.kt | 48 +++ .../revanced/patcher/util/DomFileEditor.kt | 89 +----- .../patcher/patch/PatchInitializationTest.kt | 5 +- .../patch/usage/ExampleResourcePatch.kt | 14 +- 23 files changed, 828 insertions(+), 447 deletions(-) create mode 100644 .editorconfig create mode 100644 src/main/kotlin/app/revanced/patcher/InternalApi.kt create mode 100644 src/main/kotlin/app/revanced/patcher/PatcherConfig.kt create mode 100644 src/main/kotlin/app/revanced/patcher/patch/RawResourcePatch.kt create mode 100644 src/main/kotlin/app/revanced/patcher/util/Document.kt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2d6d258 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,3 @@ +[*.{kt,kts}] +ktlint_code_style = intellij_idea +ktlint_standard_no-wildcard-imports = disabled \ No newline at end of file diff --git a/api/revanced-patcher.api b/api/revanced-patcher.api index 5c2d9ff..40c862a 100644 --- a/api/revanced-patcher.api +++ b/api/revanced-patcher.api @@ -1,5 +1,9 @@ public abstract interface class app/revanced/patcher/IntegrationsConsumer { public abstract fun acceptIntegrations (Ljava/util/List;)V + public abstract fun acceptIntegrations (Ljava/util/Set;)V +} + +public abstract interface annotation class app/revanced/patcher/InternalApi : java/lang/annotation/Annotation { } public final class app/revanced/patcher/PackageMetadata { @@ -41,8 +45,10 @@ public abstract interface class app/revanced/patcher/PatchExecutorFunction : jav } public final class app/revanced/patcher/Patcher : app/revanced/patcher/IntegrationsConsumer, app/revanced/patcher/PatchExecutorFunction, app/revanced/patcher/PatchesConsumer, java/io/Closeable, java/util/function/Supplier { + public fun (Lapp/revanced/patcher/PatcherConfig;)V public fun (Lapp/revanced/patcher/PatcherOptions;)V public fun acceptIntegrations (Ljava/util/List;)V + public fun acceptIntegrations (Ljava/util/Set;)V public fun acceptPatches (Ljava/util/List;)V public fun acceptPatches (Ljava/util/Set;)V public synthetic fun apply (Ljava/lang/Object;)Ljava/lang/Object; @@ -53,6 +59,11 @@ public final class app/revanced/patcher/Patcher : app/revanced/patcher/Integrati public final fun getContext ()Lapp/revanced/patcher/PatcherContext; } +public final class app/revanced/patcher/PatcherConfig { + public fun (Ljava/io/File;Ljava/io/File;Ljava/lang/String;Ljava/lang/String;Z)V + public synthetic fun (Ljava/io/File;Ljava/io/File;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + public final class app/revanced/patcher/PatcherContext { public final fun getPackageMetadata ()Lapp/revanced/patcher/PackageMetadata; } @@ -86,8 +97,10 @@ public final class app/revanced/patcher/PatcherResult { public static synthetic fun copy$default (Lapp/revanced/patcher/PatcherResult;Ljava/util/List;Ljava/io/File;Ljava/util/List;ILjava/lang/Object;)Lapp/revanced/patcher/PatcherResult; public fun equals (Ljava/lang/Object;)Z public final fun getDexFiles ()Ljava/util/List; + public final fun getDexFiles ()Ljava/util/Set; public final fun getDoNotCompress ()Ljava/util/List; public final fun getResourceFile ()Ljava/io/File; + public final fun getResources ()Lapp/revanced/patcher/PatcherResult$PatchedResources; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -98,6 +111,13 @@ public final class app/revanced/patcher/PatcherResult$PatchedDexFile { public final fun getStream ()Ljava/io/InputStream; } +public final class app/revanced/patcher/PatcherResult$PatchedResources { + public final fun getDeleteResources ()Ljava/util/Set; + public final fun getDoNotCompress ()Ljava/util/Set; + public final fun getOtherResources ()Ljava/io/File; + public final fun getResourcesApk ()Ljava/io/File; +} + public abstract interface class app/revanced/patcher/PatchesConsumer { public abstract fun acceptPatches (Ljava/util/List;)V public abstract fun acceptPatches (Ljava/util/Set;)V @@ -111,7 +131,7 @@ public final class app/revanced/patcher/data/BytecodeContext : app/revanced/patc public final fun findClass (Ljava/lang/String;)Lapp/revanced/patcher/util/proxy/ClassProxy; public final fun findClass (Lkotlin/jvm/functions/Function1;)Lapp/revanced/patcher/util/proxy/ClassProxy; public synthetic fun get ()Ljava/lang/Object; - public fun get ()Ljava/util/List; + public fun get ()Ljava/util/Set; public final fun getClasses ()Lapp/revanced/patcher/util/ProxyClassList; public final fun proxy (Lcom/android/tools/smali/dexlib2/iface/ClassDef;)Lapp/revanced/patcher/util/proxy/ClassProxy; public final fun toMethodWalker (Lcom/android/tools/smali/dexlib2/iface/Method;)Lapp/revanced/patcher/util/method/MethodWalker; @@ -121,11 +141,21 @@ public abstract interface class app/revanced/patcher/data/Context : java/util/fu } public final class app/revanced/patcher/data/ResourceContext : app/revanced/patcher/data/Context, java/lang/Iterable, kotlin/jvm/internal/markers/KMappedMarker { - public fun get ()Ljava/io/File; + public fun get ()Lapp/revanced/patcher/PatcherResult$PatchedResources; public synthetic fun get ()Ljava/lang/Object; public final fun get (Ljava/lang/String;)Ljava/io/File; + public final fun get (Ljava/lang/String;Z)Ljava/io/File; + public static synthetic fun get$default (Lapp/revanced/patcher/data/ResourceContext;Ljava/lang/String;ZILjava/lang/Object;)Ljava/io/File; + public final fun getDocument ()Lapp/revanced/patcher/data/ResourceContext$DocumentOperatable; public final fun getXmlEditor ()Lapp/revanced/patcher/data/ResourceContext$XmlFileHolder; public fun iterator ()Ljava/util/Iterator; + public final fun stageDelete (Lkotlin/jvm/functions/Function1;)Z +} + +public final class app/revanced/patcher/data/ResourceContext$DocumentOperatable { + public fun (Lapp/revanced/patcher/data/ResourceContext;)V + public final fun get (Ljava/io/InputStream;)Lapp/revanced/patcher/util/Document; + public final fun get (Ljava/lang/String;)Lapp/revanced/patcher/util/Document; } public final class app/revanced/patcher/data/ResourceContext$XmlFileHolder { @@ -279,6 +309,12 @@ public final class app/revanced/patcher/patch/PatchResult { public final fun getPatch ()Lapp/revanced/patcher/patch/Patch; } +public abstract class app/revanced/patcher/patch/RawResourcePatch : app/revanced/patcher/patch/Patch { + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;Ljava/util/Set;ZZ)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;Ljava/util/Set;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + public abstract class app/revanced/patcher/patch/ResourcePatch : app/revanced/patcher/patch/Patch { public fun ()V public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Set;Ljava/util/Set;ZZ)V @@ -392,6 +428,78 @@ public final class app/revanced/patcher/patch/options/PatchOptions : java/util/M public final fun values ()Ljava/util/Collection; } +public final class app/revanced/patcher/util/Document : java/io/Closeable, org/w3c/dom/Document { + public fun adoptNode (Lorg/w3c/dom/Node;)Lorg/w3c/dom/Node; + public fun appendChild (Lorg/w3c/dom/Node;)Lorg/w3c/dom/Node; + public fun cloneNode (Z)Lorg/w3c/dom/Node; + public fun close ()V + public fun compareDocumentPosition (Lorg/w3c/dom/Node;)S + public fun createAttribute (Ljava/lang/String;)Lorg/w3c/dom/Attr; + public fun createAttributeNS (Ljava/lang/String;Ljava/lang/String;)Lorg/w3c/dom/Attr; + public fun createCDATASection (Ljava/lang/String;)Lorg/w3c/dom/CDATASection; + public fun createComment (Ljava/lang/String;)Lorg/w3c/dom/Comment; + public fun createDocumentFragment ()Lorg/w3c/dom/DocumentFragment; + public fun createElement (Ljava/lang/String;)Lorg/w3c/dom/Element; + public fun createElementNS (Ljava/lang/String;Ljava/lang/String;)Lorg/w3c/dom/Element; + public fun createEntityReference (Ljava/lang/String;)Lorg/w3c/dom/EntityReference; + public fun createProcessingInstruction (Ljava/lang/String;Ljava/lang/String;)Lorg/w3c/dom/ProcessingInstruction; + public fun createTextNode (Ljava/lang/String;)Lorg/w3c/dom/Text; + public fun getAttributes ()Lorg/w3c/dom/NamedNodeMap; + public fun getBaseURI ()Ljava/lang/String; + public fun getChildNodes ()Lorg/w3c/dom/NodeList; + public fun getDoctype ()Lorg/w3c/dom/DocumentType; + public fun getDocumentElement ()Lorg/w3c/dom/Element; + public fun getDocumentURI ()Ljava/lang/String; + public fun getDomConfig ()Lorg/w3c/dom/DOMConfiguration; + public fun getElementById (Ljava/lang/String;)Lorg/w3c/dom/Element; + public fun getElementsByTagName (Ljava/lang/String;)Lorg/w3c/dom/NodeList; + public fun getElementsByTagNameNS (Ljava/lang/String;Ljava/lang/String;)Lorg/w3c/dom/NodeList; + public fun getFeature (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object; + public fun getFirstChild ()Lorg/w3c/dom/Node; + public fun getImplementation ()Lorg/w3c/dom/DOMImplementation; + public fun getInputEncoding ()Ljava/lang/String; + public fun getLastChild ()Lorg/w3c/dom/Node; + public fun getLocalName ()Ljava/lang/String; + public fun getNamespaceURI ()Ljava/lang/String; + public fun getNextSibling ()Lorg/w3c/dom/Node; + public fun getNodeName ()Ljava/lang/String; + public fun getNodeType ()S + public fun getNodeValue ()Ljava/lang/String; + public fun getOwnerDocument ()Lorg/w3c/dom/Document; + public fun getParentNode ()Lorg/w3c/dom/Node; + public fun getPrefix ()Ljava/lang/String; + public fun getPreviousSibling ()Lorg/w3c/dom/Node; + public fun getStrictErrorChecking ()Z + public fun getTextContent ()Ljava/lang/String; + public fun getUserData (Ljava/lang/String;)Ljava/lang/Object; + public fun getXmlEncoding ()Ljava/lang/String; + public fun getXmlStandalone ()Z + public fun getXmlVersion ()Ljava/lang/String; + public fun hasAttributes ()Z + public fun hasChildNodes ()Z + public fun importNode (Lorg/w3c/dom/Node;Z)Lorg/w3c/dom/Node; + public fun insertBefore (Lorg/w3c/dom/Node;Lorg/w3c/dom/Node;)Lorg/w3c/dom/Node; + public fun isDefaultNamespace (Ljava/lang/String;)Z + public fun isEqualNode (Lorg/w3c/dom/Node;)Z + public fun isSameNode (Lorg/w3c/dom/Node;)Z + public fun isSupported (Ljava/lang/String;Ljava/lang/String;)Z + public fun lookupNamespaceURI (Ljava/lang/String;)Ljava/lang/String; + public fun lookupPrefix (Ljava/lang/String;)Ljava/lang/String; + public fun normalize ()V + public fun normalizeDocument ()V + public fun removeChild (Lorg/w3c/dom/Node;)Lorg/w3c/dom/Node; + public fun renameNode (Lorg/w3c/dom/Node;Ljava/lang/String;Ljava/lang/String;)Lorg/w3c/dom/Node; + public fun replaceChild (Lorg/w3c/dom/Node;Lorg/w3c/dom/Node;)Lorg/w3c/dom/Node; + public fun setDocumentURI (Ljava/lang/String;)V + public fun setNodeValue (Ljava/lang/String;)V + public fun setPrefix (Ljava/lang/String;)V + public fun setStrictErrorChecking (Z)V + public fun setTextContent (Ljava/lang/String;)V + public fun setUserData (Ljava/lang/String;Ljava/lang/Object;Lorg/w3c/dom/UserDataHandler;)Ljava/lang/Object; + public fun setXmlStandalone (Z)V + public fun setXmlVersion (Ljava/lang/String;)V +} + public final class app/revanced/patcher/util/DomFileEditor : java/io/Closeable { public fun (Ljava/io/File;)V public fun close ()V diff --git a/build.gradle.kts b/build.gradle.kts index ce99a05..a4e8044 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - kotlin("jvm") version "1.9.10" + alias(libs.plugins.kotlin) alias(libs.plugins.binary.compatibility.validator) `maven-publish` signing @@ -36,7 +36,11 @@ dependencies { implementation(libs.apktool.lib) implementation(libs.kotlin.reflect) - compileOnly(libs.android) + // TODO: Convert project to KMP. + compileOnly(libs.android) { + // Exclude, otherwise the org.w3c.dom API breaks. + exclude(group = "xerces", module = "xmlParserAPIs") + } testImplementation(libs.kotlin.test) } diff --git a/gradle.properties b/gradle.properties index 39620e4..2e2ec5b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,3 @@ org.gradle.parallel = true org.gradle.caching = true -kotlin.code.style = official version = 19.2.1-dev.1 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85941e6..e019c38 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,18 @@ [versions] android = "4.1.1.4" -kotlin-reflect = "1.9.10" apktool-lib = "2.9.1" -kotlin-test = "1.9.20" +kotlin = "1.9.22" kotlinx-coroutines-core = "1.7.3" multidexlib2 = "3.0.3.r3" -smali = "3.0.3" -xpp3 = "1.1.4c" +smali = "3.0.4" binary-compatibility-validator = "0.13.2" +xpp3 = "1.1.4c" [libraries] android = { module = "com.google.android:android", version.ref = "android" } -kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin-reflect" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } apktool-lib = { module = "app.revanced:apktool", version.ref = "apktool-lib" } -kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin-test" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-core" } multidexlib2 = { module = "app.revanced:multidexlib2", version.ref = "multidexlib2" } smali = { module = "com.android.tools.smali:smali", version.ref = "smali" } @@ -21,3 +20,4 @@ xpp3 = { module = "xpp3:xpp3", version.ref = "xpp3" } [plugins] binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } +kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/patcher/IntegrationsConsumer.kt b/src/main/kotlin/app/revanced/patcher/IntegrationsConsumer.kt index 4a2d211..d50a212 100644 --- a/src/main/kotlin/app/revanced/patcher/IntegrationsConsumer.kt +++ b/src/main/kotlin/app/revanced/patcher/IntegrationsConsumer.kt @@ -4,5 +4,8 @@ import java.io.File @FunctionalInterface interface IntegrationsConsumer { + fun acceptIntegrations(integrations: Set) + + @Deprecated("Use acceptIntegrations(Set) instead.") fun acceptIntegrations(integrations: List) } diff --git a/src/main/kotlin/app/revanced/patcher/InternalApi.kt b/src/main/kotlin/app/revanced/patcher/InternalApi.kt new file mode 100644 index 0000000..71b5647 --- /dev/null +++ b/src/main/kotlin/app/revanced/patcher/InternalApi.kt @@ -0,0 +1,7 @@ +package app.revanced.patcher + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This is an internal API, don't rely on it.", +) +annotation class InternalApi diff --git a/src/main/kotlin/app/revanced/patcher/PackageMetadata.kt b/src/main/kotlin/app/revanced/patcher/PackageMetadata.kt index 2a87cad..4c7b9d6 100644 --- a/src/main/kotlin/app/revanced/patcher/PackageMetadata.kt +++ b/src/main/kotlin/app/revanced/patcher/PackageMetadata.kt @@ -4,6 +4,8 @@ import brut.androlib.apk.ApkInfo /** * Metadata about a package. + * + * @param apkInfo The [ApkInfo] of the apk file. */ class PackageMetadata internal constructor(internal val apkInfo: ApkInfo) { lateinit var packageName: String diff --git a/src/main/kotlin/app/revanced/patcher/Patcher.kt b/src/main/kotlin/app/revanced/patcher/Patcher.kt index b096ac3..c2e84b2 100644 --- a/src/main/kotlin/app/revanced/patcher/Patcher.kt +++ b/src/main/kotlin/app/revanced/patcher/Patcher.kt @@ -12,34 +12,38 @@ import java.util.function.Supplier import java.util.logging.Logger /** - * ReVanced Patcher. + * A Patcher. * - * @param options The options for the patcher. + * @param config The configuration to use for the patcher. */ class Patcher( - private val options: PatcherOptions, + private val config: PatcherConfig, ) : PatchExecutorFunction, PatchesConsumer, IntegrationsConsumer, Supplier, Closeable { private val logger = Logger.getLogger(Patcher::class.java.name) /** - * The context of ReVanced [Patcher]. - * This holds the current state of the patcher. + * A context for the patcher containing the current state of the patcher. */ - val context = PatcherContext(options) + val context = PatcherContext(config) + + @Suppress("DEPRECATION") + @Deprecated("Use Patcher(PatcherConfig) instead.") + constructor( + patcherOptions: PatcherOptions, + ) : this( + PatcherConfig( + patcherOptions.inputFile, + patcherOptions.resourceCachePath, + patcherOptions.aaptBinaryPath, + patcherOptions.frameworkFileDirectory, + patcherOptions.multithreadingDexFileWriter, + ), + ) init { - context.resourceContext.decodeResources(ResourceContext.ResourceDecodingMode.MANIFEST_ONLY) + context.resourceContext.decodeResources(ResourceContext.ResourceMode.NONE) } - // TODO: Fix circular dependency detection. - // /** - // * Add [Patch]es to ReVanced [Patcher]. - // * It is not guaranteed that all supplied [Patch]es will be accepted, if an exception is thrown. - // * - // * @param patches The [Patch]es to add. - // * @throws PatcherException.CircularDependencyException If a circular dependency is detected. - // */ - /** * Add [Patch]es to ReVanced [Patcher]. * @@ -61,29 +65,15 @@ class Patcher( } // Add all patches and their dependencies to the context. - for (patch in patches) context.executablePatches.putIfAbsent(patch::class, patch) ?: run { - context.allPatches[patch::class] = patch + patches.forEach { patch -> + context.executablePatches.putIfAbsent(patch::class, patch) ?: run { + context.allPatches[patch::class] = patch - patch.dependencies?.forEach { it.putDependenciesRecursively() } - } - - /* TODO: Fix circular dependency detection. - val graph = mutableMapOf>() - fun PatchClass.visit() { - if (this in graph) return - - val group = graph.getOrPut(this) { mutableListOf(this) } - - val dependencies = context.allPatches[this]!!.manifest.dependencies ?: return - dependencies.forEach { dependency -> - if (group == graph[dependency]) - throw PatcherException.CircularDependencyException(context.allPatches[this]!!.manifest.name) - - graph[dependency] = group.apply { add(dependency) } - dependency.visit() + patch.dependencies?.forEach { it.putDependenciesRecursively() } } } - */ + + // TODO: Detect circular dependencies. /** * Returns true if at least one patch or its dependencies matches the given predicate. @@ -96,12 +86,15 @@ class Patcher( } ?: false context.allPatches.values.let { patches -> - // Determine, if resource patching is required. - for (patch in patches) - if (patch.anyRecursively { patch is ResourcePatch }) { - options.resourceDecodingMode = ResourceContext.ResourceDecodingMode.FULL - break - } + // Determine the resource mode. + + config.resourceMode = if (patches.any { patch -> patch.anyRecursively { it is ResourcePatch } }) { + ResourceContext.ResourceMode.FULL + } else if (patches.any { patch -> patch.anyRecursively { it is RawResourcePatch } }) { + ResourceContext.ResourceMode.RAW_ONLY + } else { + ResourceContext.ResourceMode.NONE + } // Determine, if merging integrations is required. for (patch in patches) @@ -117,10 +110,16 @@ class Patcher( * * @param integrations The integrations to add. Must be a DEX file or container of DEX files. */ - override fun acceptIntegrations(integrations: List) { + override fun acceptIntegrations(integrations: Set) { context.bytecodeContext.integrations.addAll(integrations) } + @Deprecated( + "Use acceptIntegrations(Set) instead.", + ReplaceWith("acceptIntegrations(integrations.toSet())"), + ) + override fun acceptIntegrations(integrations: List) = acceptIntegrations(integrations.toSet()) + /** * Execute [Patch]es that were added to ReVanced [Patcher]. * @@ -173,6 +172,9 @@ class Patcher( patch.fingerprints.resolveUsingLookupMap(context.bytecodeContext) patch.execute(context.bytecodeContext) } + is RawResourcePatch -> { + patch.execute(context.resourceContext) + } is ResourcePatch -> { patch.execute(context.resourceContext) } @@ -191,8 +193,8 @@ class Patcher( LookupMap.initializeLookupMaps(context.bytecodeContext) // Prevent from decoding the app manifest twice if it is not needed. - if (options.resourceDecodingMode == ResourceContext.ResourceDecodingMode.FULL) { - context.resourceContext.decodeResources(ResourceContext.ResourceDecodingMode.FULL) + if (config.resourceMode != ResourceContext.ResourceMode.NONE) { + context.resourceContext.decodeResources(config.resourceMode) } logger.info("Executing patches") @@ -259,10 +261,10 @@ class Patcher( * * @return The [PatcherResult] containing the patched input files. */ + @OptIn(InternalApi::class) override fun get() = PatcherResult( context.bytecodeContext.get(), context.resourceContext.get(), - context.packageMetadata.apkInfo.doNotCompress?.toList(), ) } diff --git a/src/main/kotlin/app/revanced/patcher/PatcherConfig.kt b/src/main/kotlin/app/revanced/patcher/PatcherConfig.kt new file mode 100644 index 0000000..ca47654 --- /dev/null +++ b/src/main/kotlin/app/revanced/patcher/PatcherConfig.kt @@ -0,0 +1,72 @@ +package app.revanced.patcher + +import app.revanced.patcher.data.ResourceContext +import brut.androlib.Config +import java.io.File +import java.util.logging.Logger + +/** + * The configuration for the patcher. + * + * @param apkFile The apk file to patch. + * @param temporaryFilesPath A path to a folder to store temporary files in. + * @param aaptBinaryPath A path to a custom aapt binary. + * @param frameworkFileDirectory A path to the directory to cache the framework file in. + * @param multithreadingDexFileWriter Whether to use multiple threads for writing dex files. + * This has impact on memory usage and performance. + */ +class PatcherConfig( + internal val apkFile: File, + private val temporaryFilesPath: File = File("revanced-temporary-files"), + aaptBinaryPath: String? = null, + frameworkFileDirectory: String? = null, + internal val multithreadingDexFileWriter: Boolean = false, +) { + private val logger = Logger.getLogger(PatcherConfig::class.java.name) + + /** + * The mode to use for resource decoding and compiling. + * + * @see ResourceContext.ResourceMode + */ + internal var resourceMode = ResourceContext.ResourceMode.NONE + + /** + * The configuration for decoding and compiling resources. + */ + internal val resourceConfig = + Config.getDefaultConfig().apply { + useAapt2 = true + aaptPath = aaptBinaryPath ?: "" + frameworkDirectory = frameworkFileDirectory + } + + /** + * The path to the temporary apk files directory. + */ + internal val apkFiles = temporaryFilesPath.resolve("apk") + + /** + * The path to the temporary patched files directory. + */ + internal val patchedFiles = temporaryFilesPath.resolve("patched") + + /** + * Initialize the temporary files' directories. + * This will delete the existing temporary files directory if it exists. + */ + internal fun initializeTemporaryFilesDirectories() { + temporaryFilesPath.apply { + if (exists()) { + logger.info("Deleting existing temporary files directory") + + if (!deleteRecursively()) { + logger.severe("Failed to delete existing temporary files directory") + } + } + } + + apkFiles.mkdirs() + patchedFiles.mkdirs() + } +} diff --git a/src/main/kotlin/app/revanced/patcher/PatcherContext.kt b/src/main/kotlin/app/revanced/patcher/PatcherContext.kt index 03bdaf6..7824b4c 100644 --- a/src/main/kotlin/app/revanced/patcher/PatcherContext.kt +++ b/src/main/kotlin/app/revanced/patcher/PatcherContext.kt @@ -7,15 +7,16 @@ import brut.androlib.apk.ApkInfo import brut.directory.ExtFile /** - * A context for ReVanced [Patcher]. + * A context for the patcher containing the current state of the patcher. * - * @param options The [PatcherOptions] used to create this context. + * @param config The configuration for the patcher. */ -class PatcherContext internal constructor(options: PatcherOptions) { +@Suppress("MemberVisibilityCanBePrivate") +class PatcherContext internal constructor(config: PatcherConfig) { /** - * [PackageMetadata] of the supplied [PatcherOptions.inputFile]. + * [PackageMetadata] of the supplied [PatcherConfig.apkFile]. */ - val packageMetadata = PackageMetadata(ApkInfo(ExtFile(options.inputFile))) + val packageMetadata = PackageMetadata(ApkInfo(ExtFile(config.apkFile))) /** * The map of [Patch]es associated by their [PatchClass]. @@ -28,14 +29,12 @@ class PatcherContext internal constructor(options: PatcherOptions) { internal val allPatches = mutableMapOf>() /** - * The [ResourceContext] of this [PatcherContext]. - * This holds the current state of the resources. + * A context for the patcher containing the current state of the resources. */ - internal val resourceContext = ResourceContext(this, options) + internal val resourceContext = ResourceContext(packageMetadata, config) /** - * The [BytecodeContext] of this [PatcherContext]. - * This holds the current state of the bytecode. + * A context for the patcher containing the current state of the bytecode. */ - internal val bytecodeContext = BytecodeContext(options) + internal val bytecodeContext = BytecodeContext(config) } diff --git a/src/main/kotlin/app/revanced/patcher/PatcherOptions.kt b/src/main/kotlin/app/revanced/patcher/PatcherOptions.kt index 9c39557..f2061cc 100644 --- a/src/main/kotlin/app/revanced/patcher/PatcherOptions.kt +++ b/src/main/kotlin/app/revanced/patcher/PatcherOptions.kt @@ -1,19 +1,8 @@ package app.revanced.patcher -import app.revanced.patcher.data.ResourceContext -import brut.androlib.Config import java.io.File -import java.util.logging.Logger -/** - * Options for ReVanced [Patcher]. - * @param inputFile The input file to patch. - * @param resourceCachePath The path to the directory to use for caching resources. - * @param aaptBinaryPath The path to a custom aapt binary. - * @param frameworkFileDirectory The path to the directory to cache the framework file in. - * @param multithreadingDexFileWriter Whether to use multiple threads for writing dex files. - * This can impact memory usage. - */ +@Deprecated("Use PatcherConfig instead.") data class PatcherOptions( internal val inputFile: File, internal val resourceCachePath: File = File("revanced-resource-cache"), @@ -21,34 +10,16 @@ data class PatcherOptions( internal val frameworkFileDirectory: String? = null, internal val multithreadingDexFileWriter: Boolean = false, ) { - private val logger = Logger.getLogger(PatcherOptions::class.java.name) + @Deprecated("This method will be removed in the future.") + fun recreateResourceCacheDirectory(): File { + PatcherConfig( + inputFile, + resourceCachePath, + aaptBinaryPath, + frameworkFileDirectory, + multithreadingDexFileWriter, + ).initializeTemporaryFilesDirectories() - /** - * The mode to use for resource decoding. - * @see ResourceContext.ResourceDecodingMode - */ - internal var resourceDecodingMode = ResourceContext.ResourceDecodingMode.MANIFEST_ONLY - - /** - * The configuration to use for resource decoding and compiling. - */ - internal val resourceConfig = - Config.getDefaultConfig().apply { - useAapt2 = true - aaptPath = aaptBinaryPath ?: "" - frameworkDirectory = frameworkFileDirectory - } - - fun recreateResourceCacheDirectory() = - resourceCachePath.also { - if (it.exists()) { - logger.info("Deleting existing resource cache directory") - - if (!it.deleteRecursively()) { - logger.severe("Failed to delete existing resource cache directory") - } - } - - it.mkdirs() - } + return resourceCachePath + } } diff --git a/src/main/kotlin/app/revanced/patcher/PatcherResult.kt b/src/main/kotlin/app/revanced/patcher/PatcherResult.kt index 0df8634..302eda2 100644 --- a/src/main/kotlin/app/revanced/patcher/PatcherResult.kt +++ b/src/main/kotlin/app/revanced/patcher/PatcherResult.kt @@ -2,22 +2,121 @@ package app.revanced.patcher import java.io.File import java.io.InputStream +import kotlin.jvm.internal.Intrinsics /** * The result of a patcher. + * * @param dexFiles The patched dex files. - * @param resourceFile File containing resources that need to be extracted into the APK. - * @param doNotCompress List of relative paths of files to exclude from compressing. + * @param resources The patched resources. */ -data class PatcherResult( - val dexFiles: List, - val resourceFile: File?, - val doNotCompress: List? = null, +@Suppress("MemberVisibilityCanBePrivate") +class PatcherResult internal constructor( + val dexFiles: Set, + val resources: PatchedResources?, ) { + @Deprecated("This method is not used anymore") + constructor( + dexFiles: List, + resourceFile: File?, + doNotCompress: List? = null, + ) : this(dexFiles.toSet(), PatchedResources(resourceFile, null, doNotCompress?.toSet() ?: emptySet(), emptySet())) + + @Deprecated("This method is not used anymore") + fun component1(): List { + return dexFiles.toList() + } + + @Deprecated("This method is not used anymore") + fun component2(): File? { + return resources?.resourcesApk + } + + @Deprecated("This method is not used anymore") + fun component3(): List? { + return resources?.doNotCompress?.toList() + } + + @Deprecated("This method is not used anymore") + fun copy( + dexFiles: List, + resourceFile: File?, + doNotCompress: List? = null, + ): PatcherResult { + return PatcherResult( + dexFiles.toSet(), + PatchedResources( + resourceFile, + null, + doNotCompress?.toSet() ?: emptySet(), + emptySet(), + ), + ) + } + + @Deprecated("This method is not used anymore") + override fun toString(): String { + return (("PatcherResult(dexFiles=" + this.dexFiles + ", resourceFile=" + this.resources?.resourcesApk) + ", doNotCompress=" + this.resources?.doNotCompress) + ")" + } + + @Deprecated("This method is not used anymore") + override fun hashCode(): Int { + val result = dexFiles.hashCode() + return ( + ( + (result * 31) + + (if (this.resources?.resourcesApk == null) 0 else this.resources?.resourcesApk.hashCode()) + ) * 31 + ) + + (if (this.resources?.doNotCompress == null) 0 else this.resources?.doNotCompress.hashCode()) + } + + @Deprecated("This method is not used anymore") + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other is PatcherResult) { + return Intrinsics.areEqual(this.dexFiles, other.dexFiles) && Intrinsics.areEqual( + this.resources?.resourcesApk, + other.resources?.resourcesApk, + ) && Intrinsics.areEqual(this.resources?.doNotCompress, other.resources?.doNotCompress) + } + return false + } + + @Deprecated("This method is not used anymore") + fun getDexFiles() = component1() + + @Deprecated("This method is not used anymore") + fun getResourceFile() = component2() + + @Deprecated("This method is not used anymore") + fun getDoNotCompress() = component3() + /** - * Wrapper for dex files. + * A dex file. + * * @param name The original name of the dex file. * @param stream The dex file as [InputStream]. */ - class PatchedDexFile(val name: String, val stream: InputStream) + class PatchedDexFile + // TODO: Add internal modifier. + @Deprecated("This constructor will be removed in the future.") + constructor(val name: String, val stream: InputStream) + + /** + * The resources of a patched apk. + * + * @param resourcesApk The compiled resources.apk file. + * @param otherResources The directory containing other resources files. + * @param doNotCompress List of files that should not be compressed. + * @param deleteResources List of predicates about resources that should be deleted. + */ + class PatchedResources internal constructor( + val resourcesApk: File?, + val otherResources: File?, + val doNotCompress: Set, + val deleteResources: Set<(String) -> Boolean>, + ) } diff --git a/src/main/kotlin/app/revanced/patcher/data/BytecodeContext.kt b/src/main/kotlin/app/revanced/patcher/data/BytecodeContext.kt index 5629346..ac4bf7d 100644 --- a/src/main/kotlin/app/revanced/patcher/data/BytecodeContext.kt +++ b/src/main/kotlin/app/revanced/patcher/data/BytecodeContext.kt @@ -1,7 +1,8 @@ package app.revanced.patcher.data +import app.revanced.patcher.InternalApi +import app.revanced.patcher.PatcherConfig import app.revanced.patcher.PatcherContext -import app.revanced.patcher.PatcherOptions import app.revanced.patcher.PatcherResult import app.revanced.patcher.patch.Patch import app.revanced.patcher.util.ClassMerger.merge @@ -21,160 +22,163 @@ import java.io.Flushable import java.util.logging.Logger /** - * A context for bytecode. - * This holds the current state of the bytecode. + * A context for the patcher containing the current state of the bytecode. * - * @param options The [PatcherOptions] used to create this context. + * @param config The [PatcherConfig] used to create this context. */ -class BytecodeContext internal constructor(private val options: PatcherOptions) : - Context> { - private val logger = Logger.getLogger(BytecodeContext::class.java.name) +@Suppress("MemberVisibilityCanBePrivate") +class BytecodeContext internal constructor(private val config: PatcherConfig) : + Context> { + private val logger = Logger.getLogger(BytecodeContext::class.java.name) + + /** + * [Opcodes] of the supplied [PatcherConfig.apkFile]. + */ + internal lateinit var opcodes: Opcodes + + /** + * The list of classes. + */ + val classes by lazy { + ProxyClassList( + MultiDexIO.readDexFile( + true, + config.apkFile, + BasicDexFileNamer(), + null, + null, + ).also { opcodes = it.opcodes }.classes.toMutableSet(), + ) + } + + /** + * The [Integrations] of this [PatcherContext]. + */ + internal val integrations = Integrations() + + /** + * Find a class by a given class name. + * + * @param className The name of the class. + * @return A proxy for the first class that matches the class name. + */ + fun findClass(className: String) = findClass { it.type.contains(className) } + + /** + * Find a class by a given predicate. + * + * @param predicate A predicate to match the class. + * @return A proxy for the first class that matches the predicate. + */ + fun findClass(predicate: (ClassDef) -> Boolean) = + // if we already proxied the class matching the predicate... + classes.proxies.firstOrNull { predicate(it.immutableClass) } + ?: // else resolve the class to a proxy and return it, if the predicate is matching a class + classes.find(predicate)?.let { proxy(it) } + + /** + * Proxy a class. + * This will allow the class to be modified. + * + * @param classDef The class to proxy. + * @return A proxy for the class. + */ + fun proxy(classDef: ClassDef) = + this.classes.proxies.find { it.immutableClass.type == classDef.type } ?: let { + ClassProxy(classDef).also { this.classes.add(it) } + } + + /** + * Create a [MethodWalker] instance for the current [BytecodeContext]. + * + * @param startMethod The method to start at. + * @return A [MethodWalker] instance. + */ + fun toMethodWalker(startMethod: Method) = MethodWalker(this, startMethod) + + /** + * Compile bytecode from the [BytecodeContext]. + * + * @return The compiled bytecode. + */ + @InternalApi + override fun get(): Set { + logger.info("Compiling patched dex files") + + val patchedDexFileResults = + config.patchedFiles.resolve("dex").also { + it.deleteRecursively() // Make sure the directory is empty. + it.mkdirs() + }.apply { + MultiDexIO.writeDexFile( + true, + if (config.multithreadingDexFileWriter) -1 else 1, + this, + BasicDexFileNamer(), + object : DexFile { + override fun getClasses() = this@BytecodeContext.classes.also(ProxyClassList::replaceClasses) + + override fun getOpcodes() = this@BytecodeContext.opcodes + }, + DexIO.DEFAULT_MAX_DEX_POOL_SIZE, + ) { _, entryName, _ -> logger.info("Compiled $entryName") } + }.listFiles(FileFilter { it.isFile })!!.map { + PatcherResult.PatchedDexFile(it.name, it.inputStream()) + }.toSet() + + System.gc() + + return patchedDexFileResults + } + + /** + * The integrations of a [PatcherContext]. + */ + internal inner class Integrations : MutableList by mutableListOf(), Flushable { + /** + * Whether to merge integrations. + * Set to true, if the field requiresIntegrations of any supplied [Patch] is true. + */ + var merge = false /** - * [Opcodes] of the supplied [PatcherOptions.inputFile]. + * Merge integrations into the [BytecodeContext] and flush all [Integrations]. */ - internal lateinit var opcodes: Opcodes + override fun flush() { + if (!merge) return - /** - * The list of classes. - */ - val classes by lazy { - ProxyClassList( + logger.info("Merging integrations") + + val classMap = classes.associateBy { it.type } + + this@Integrations.forEach { integrations -> MultiDexIO.readDexFile( true, - options.inputFile, + integrations, BasicDexFileNamer(), null, null, - ).also { opcodes = it.opcodes }.classes.toMutableSet(), - ) - } + ).classes.forEach classDef@{ classDef -> + val existingClass = + classMap[classDef.type] ?: run { + logger.fine("Adding $classDef") + classes.add(classDef) + return@classDef + } - /** - * The [Integrations] of this [PatcherContext]. - */ - internal val integrations = Integrations() + logger.fine("$classDef exists. Adding missing methods and fields.") - /** - * Find a class by a given class name. - * - * @param className The name of the class. - * @return A proxy for the first class that matches the class name. - */ - fun findClass(className: String) = findClass { it.type.contains(className) } - - /** - * Find a class by a given predicate. - * - * @param predicate A predicate to match the class. - * @return A proxy for the first class that matches the predicate. - */ - fun findClass(predicate: (ClassDef) -> Boolean) = - // if we already proxied the class matching the predicate... - classes.proxies.firstOrNull { predicate(it.immutableClass) } - ?: // else resolve the class to a proxy and return it, if the predicate is matching a class - classes.find(predicate)?.let { proxy(it) } - - /** - * Proxy a class. - * This will allow the class to be modified. - * - * @param classDef The class to proxy. - * @return A proxy for the class. - */ - fun proxy(classDef: ClassDef) = - this.classes.proxies.find { it.immutableClass.type == classDef.type } ?: let { - ClassProxy(classDef).also { this.classes.add(it) } - } - - /** - * Create a [MethodWalker] instance for the current [BytecodeContext]. - * - * @param startMethod The method to start at. - * @return A [MethodWalker] instance. - */ - fun toMethodWalker(startMethod: Method) = MethodWalker(this, startMethod) - - /** - * Compile bytecode from the [BytecodeContext]. - * - * @return The compiled bytecode. - */ - override fun get(): List { - logger.info("Compiling patched dex files") - - val patchedDexFileResults = - options.resourceCachePath.resolve("dex").also { - it.deleteRecursively() // Make sure the directory is empty. - it.mkdirs() - }.apply { - MultiDexIO.writeDexFile( - true, - if (options.multithreadingDexFileWriter) -1 else 1, - this, - BasicDexFileNamer(), - object : DexFile { - override fun getClasses() = this@BytecodeContext.classes.also(ProxyClassList::replaceClasses) - - override fun getOpcodes() = this@BytecodeContext.opcodes - }, - DexIO.DEFAULT_MAX_DEX_POOL_SIZE, - ) { _, entryName, _ -> logger.info("Compiled $entryName") } - }.listFiles(FileFilter { it.isFile })!!.map { PatcherResult.PatchedDexFile(it.name, it.inputStream()) } - - System.gc() - - return patchedDexFileResults - } - - /** - * The integrations of a [PatcherContext]. - */ - internal inner class Integrations : MutableList by mutableListOf(), Flushable { - /** - * Whether to merge integrations. - * Set to true, if the field requiresIntegrations of any supplied [Patch] is true. - */ - var merge = false - - /** - * Merge integrations into the [BytecodeContext] and flush all [Integrations]. - */ - override fun flush() { - if (!merge) return - - logger.info("Merging integrations") - - val classMap = classes.associateBy { it.type } - - this@Integrations.forEach { integrations -> - MultiDexIO.readDexFile( - true, - integrations, - BasicDexFileNamer(), - null, - null, - ).classes.forEach classDef@{ classDef -> - val existingClass = - classMap[classDef.type] ?: run { - logger.fine("Adding $classDef") - classes.add(classDef) - return@classDef - } - - logger.fine("$classDef exists. Adding missing methods and fields.") - - existingClass.merge(classDef, this@BytecodeContext).let { mergedClass -> - // If the class was merged, replace the original class with the merged class. - if (mergedClass === existingClass) return@let - classes.apply { - remove(existingClass) - add(mergedClass) - } + existingClass.merge(classDef, this@BytecodeContext).let { mergedClass -> + // If the class was merged, replace the original class with the merged class. + if (mergedClass === existingClass) return@let + classes.apply { + remove(existingClass) + add(mergedClass) } } } - clear() } + clear() } } +} diff --git a/src/main/kotlin/app/revanced/patcher/data/ResourceContext.kt b/src/main/kotlin/app/revanced/patcher/data/ResourceContext.kt index d94c108..e9a29b2 100644 --- a/src/main/kotlin/app/revanced/patcher/data/ResourceContext.kt +++ b/src/main/kotlin/app/revanced/patcher/data/ResourceContext.kt @@ -1,7 +1,10 @@ package app.revanced.patcher.data -import app.revanced.patcher.PatcherContext -import app.revanced.patcher.PatcherOptions +import app.revanced.patcher.InternalApi +import app.revanced.patcher.PackageMetadata +import app.revanced.patcher.PatcherConfig +import app.revanced.patcher.PatcherResult +import app.revanced.patcher.util.Document import app.revanced.patcher.util.DomFileEditor import brut.androlib.AaptInvoker import brut.androlib.ApkDecoder @@ -19,72 +22,80 @@ import java.nio.file.Files import java.util.logging.Logger /** - * A context for resources. - * This holds the current state of the resources. + * A context for the patcher containing the current state of the resources. * - * @param context The [PatcherContext] to create the context for. + * @param packageMetadata The [PackageMetadata] of the apk file. + * @param config The [PatcherConfig] used to create this context. */ class ResourceContext internal constructor( - private val context: PatcherContext, - private val options: PatcherOptions, -) : Context, Iterable { + private val packageMetadata: PackageMetadata, + private val config: PatcherConfig, +) : Context, Iterable { private val logger = Logger.getLogger(ResourceContext::class.java.name) + /** + * Read and write documents in the [PatcherConfig.apkFiles]. + */ + val document = DocumentOperatable() + + @Deprecated("Use document instead.") val xmlEditor = XmlFileHolder() /** - * Decode resources for the patcher. - * - * @param mode The [ResourceDecodingMode] to use when decoding. + * Predicate to delete resources from [PatcherConfig.apkFiles]. */ - internal fun decodeResources(mode: ResourceDecodingMode) = - with(context.packageMetadata.apkInfo) { + private val deleteResources = mutableSetOf<(String) -> Boolean>() + + /** + * Decode resources of [PatcherConfig.apkFile]. + * + * @param mode The [ResourceMode] to use. + */ + internal fun decodeResources(mode: ResourceMode) = + with(packageMetadata.apkInfo) { + config.initializeTemporaryFilesDirectories() + // Needed to decode resources. - val resourcesDecoder = ResourcesDecoder(options.resourceConfig, this) + val resourcesDecoder = ResourcesDecoder(config.resourceConfig, this) - when (mode) { - ResourceDecodingMode.FULL -> { - val outDir = options.recreateResourceCacheDirectory() + if (mode == ResourceMode.FULL) { + logger.info("Decoding resources") - logger.info("Decoding resources") + resourcesDecoder.decodeResources(config.apkFiles) + resourcesDecoder.decodeManifest(config.apkFiles) - resourcesDecoder.decodeResources(outDir) - resourcesDecoder.decodeManifest(outDir) + // Needed to record uncompressed files. + val apkDecoder = ApkDecoder(config.resourceConfig, this) + apkDecoder.recordUncompressedFiles(resourcesDecoder.resFileMapping) - // Needed to record uncompressed files. - val apkDecoder = ApkDecoder(options.resourceConfig, this) - apkDecoder.recordUncompressedFiles(resourcesDecoder.resFileMapping) + usesFramework = + UsesFramework().apply { + ids = resourcesDecoder.resTable.listFramePackages().map { it.id } + } + } else { + logger.info("Decoding app manifest") - usesFramework = - UsesFramework().apply { - ids = resourcesDecoder.resTable.listFramePackages().map { it.id } + // Decode manually instead of using resourceDecoder.decodeManifest + // because it does not support decoding to an OutputStream. + XmlPullStreamDecoder( + AndroidManifestResourceParser(resourcesDecoder.resTable), + resourcesDecoder.resXmlSerializer, + ).decodeManifest( + apkFile.directory.getFileInput("AndroidManifest.xml"), + // Older Android versions do not support OutputStream.nullOutputStream() + object : OutputStream() { + override fun write(b: Int) { // Do nothing. } - } + }, + ) - ResourceDecodingMode.MANIFEST_ONLY -> { - logger.info("Decoding app manifest") - - // Decode manually instead of using resourceDecoder.decodeManifest - // because it does not support decoding to an OutputStream. - XmlPullStreamDecoder( - AndroidManifestResourceParser(resourcesDecoder.resTable), - resourcesDecoder.resXmlSerializer, - ).decodeManifest( - apkFile.directory.getFileInput("AndroidManifest.xml"), - // Older Android versions do not support OutputStream.nullOutputStream() - object : OutputStream() { - override fun write(b: Int) { // do nothing - } - }, - ) - - // Get the package name and version from the manifest using the XmlPullStreamDecoder. - // XmlPullStreamDecoder.decodeManifest() sets metadata.apkInfo. - context.packageMetadata.let { metadata -> - metadata.packageName = resourcesDecoder.resTable.packageRenamed - versionInfo.let { - metadata.packageVersion = it.versionName ?: it.versionCode - } + // Get the package name and version from the manifest using the XmlPullStreamDecoder. + // XmlPullStreamDecoder.decodeManifest() sets metadata.apkInfo. + packageMetadata.let { metadata -> + metadata.packageName = resourcesDecoder.resTable.packageRenamed + versionInfo.let { + metadata.packageVersion = it.versionName ?: it.versionCode + } /* The ResTable if flagged as sparse if the main package is not loaded, which is the case here, @@ -94,74 +105,147 @@ class ResourceContext internal constructor( Set this to false again to prevent the ResTable from being flagged as sparse falsely. */ - metadata.apkInfo.sparseResources = false - } + metadata.apkInfo.sparseResources = false } } } - operator fun get(path: String) = options.resourceCachePath.resolve(path) - - override fun iterator() = options.resourceCachePath.walkTopDown().iterator() - /** - * Compile resources from the [ResourceContext]. + * Compile resources in [PatcherConfig.apkFiles]. * - * @return The compiled resources. + * @return The [PatcherResult.PatchedResources]. */ - override fun get(): File? { - var resourceFile: File? = null + @InternalApi + override fun get(): PatcherResult.PatchedResources? { + if (config.resourceMode == ResourceMode.NONE) return null - if (options.resourceDecodingMode == ResourceDecodingMode.FULL) { - logger.info("Compiling modified resources") + logger.info("Compiling modified resources") - val cacheDirectory = ExtFile(options.resourceCachePath) - val aaptFile = - cacheDirectory.resolve("aapt_temp_file").also { - Files.deleteIfExists(it.toPath()) - }.also { resourceFile = it } + val resources = config.patchedFiles.resolve("resources").also { it.mkdirs() } - try { - AaptInvoker( - options.resourceConfig, - context.packageMetadata.apkInfo, - ).invokeAapt( - aaptFile, - cacheDirectory.resolve("AndroidManifest.xml").also { - ResXmlPatcher.fixingPublicAttrsInProviderAttributes(it) - }, - cacheDirectory.resolve("res"), - null, - null, - context.packageMetadata.apkInfo.usesFramework.let { usesFramework -> - usesFramework.ids.map { id -> - Framework(options.resourceConfig).getFrameworkApk(id, usesFramework.tag) - }.toTypedArray() - }, - ) - } finally { - cacheDirectory.close() + val resourcesApkFile = + if (config.resourceMode == ResourceMode.FULL) { + resources.resolve("resources.apk").apply { + // Compile the resources.apk file. + AaptInvoker( + config.resourceConfig, + packageMetadata.apkInfo, + ).invokeAapt( + resources.resolve("resources.apk"), + config.apkFiles.resolve("AndroidManifest.xml").also { + ResXmlPatcher.fixingPublicAttrsInProviderAttributes(it) + }, + config.apkFiles.resolve("res"), + null, + null, + packageMetadata.apkInfo.usesFramework.let { usesFramework -> + usesFramework.ids.map { id -> + Framework(config.resourceConfig).getFrameworkApk(id, usesFramework.tag) + }.toTypedArray() + }, + ) + } + } else { + null } - } - return resourceFile + val otherFiles = + config.apkFiles.listFiles()!!.filter { + // Excluded because present in resources.other. + // TODO: We are reusing config.apkFiles as a temporarily directory for extracting resources. + // This is not ideal as it could conflict with files such as the ones that we filter here. + // The problem is that ResourceContext#get returns a File relative to config.apkFiles, + // and we need to extract files to that directory. + // A solution would be to use config.apkFiles as the working directory for the patching process. + // Once all patches have been executed, we can move the decoded resources to a new directory. + // The filters wouldn't be needed anymore. + // For now, we assume that the files we filter here are not needed for the patching process. + it.name != "AndroidManifest.xml" && + it.name != "res" && + // Generated by Androlib. + it.name != "build" + } + + val otherResourceFiles = + if (otherFiles.isNotEmpty()) { + // Move the other resources files. + resources.resolve("other").also { it.mkdirs() }.apply { + otherFiles.forEach { file -> + Files.move(file.toPath(), resolve(file.name).toPath()) + } + } + } else { + null + } + + return PatcherResult.PatchedResources( + resourcesApkFile, + otherResourceFiles, + packageMetadata.apkInfo.doNotCompress?.toSet() ?: emptySet(), + deleteResources, + ) } /** - * The type of decoding the resources. + * Get a file from [PatcherConfig.apkFiles]. + * + * @param path The path of the file. + * @param copy Whether to copy the file from [PatcherConfig.apkFile] if it does not exist yet in [PatcherConfig.apkFiles]. */ - internal enum class ResourceDecodingMode { + operator fun get( + path: String, + copy: Boolean = true, + ) = config.apkFiles.resolve(path).apply { + if (copy && !exists()) { + with(ExtFile(config.apkFile).directory) { + if (containsFile(path) || containsDir(path)) { + copyToDir(config.apkFiles, path) + } + } + } + } + + /** + * Stage a file to be deleted from [PatcherConfig.apkFile]. + * + * @param shouldDelete The predicate to stage the file for deletion given its name. + */ + fun stageDelete(shouldDelete: (String) -> Boolean) = deleteResources.add(shouldDelete) + + @Deprecated("Use get(String, Boolean) instead.", ReplaceWith("get(path, false)")) + operator fun get(path: String) = get(path, false) + + @Deprecated("Use get(String, Boolean) instead.") + override fun iterator(): Iterator = config.apkFiles.listFiles()!!.iterator() + + /** + * How to handle resources decoding and compiling. + */ + internal enum class ResourceMode { /** - * Decode all resources. + * Decode and compile all resources. */ FULL, /** - * Decode the manifest file only. + * Only extract resources from the APK. + * The AndroidManifest.xml and resources inside /res are not decoded or compiled. */ - MANIFEST_ONLY, + RAW_ONLY, + + /** + * Do not decode or compile any resources. + */ + NONE, } + inner class DocumentOperatable { + operator fun get(inputStream: InputStream) = Document(inputStream) + + operator fun get(path: String) = Document(this@ResourceContext[path]) + } + + @Deprecated("Use DocumentOperatable instead.") inner class XmlFileHolder { operator fun get(inputStream: InputStream) = DomFileEditor(inputStream) diff --git a/src/main/kotlin/app/revanced/patcher/patch/BytecodePatch.kt b/src/main/kotlin/app/revanced/patcher/patch/BytecodePatch.kt index 897b60b..59e28ba 100644 --- a/src/main/kotlin/app/revanced/patcher/patch/BytecodePatch.kt +++ b/src/main/kotlin/app/revanced/patcher/patch/BytecodePatch.kt @@ -7,10 +7,10 @@ import app.revanced.patcher.fingerprint.MethodFingerprint import java.io.Closeable /** - * A ReVanced [Patch] that accesses a [BytecodeContext]. + * A [Patch] that accesses a [BytecodeContext]. * * If an implementation of [Patch] also implements [Closeable] - * it will be closed in reverse execution order of patches executed by ReVanced [Patcher]. + * it will be closed in reverse execution order of patches executed by [Patcher]. */ @Suppress("unused") abstract class BytecodePatch : Patch { diff --git a/src/main/kotlin/app/revanced/patcher/patch/Patch.kt b/src/main/kotlin/app/revanced/patcher/patch/Patch.kt index 256f2fd..8ec2caf 100644 --- a/src/main/kotlin/app/revanced/patcher/patch/Patch.kt +++ b/src/main/kotlin/app/revanced/patcher/patch/Patch.kt @@ -10,10 +10,10 @@ import app.revanced.patcher.patch.options.PatchOptions import java.io.Closeable /** - * A ReVanced patch. + * A patch. * * If an implementation of [Patch] also implements [Closeable] - * it will be closed in reverse execution order of patches executed by ReVanced [Patcher]. + * it will be closed in reverse execution order of patches executed by [Patcher]. * * @param T The [Context] type this patch will work on. */ diff --git a/src/main/kotlin/app/revanced/patcher/patch/RawResourcePatch.kt b/src/main/kotlin/app/revanced/patcher/patch/RawResourcePatch.kt new file mode 100644 index 0000000..b610242 --- /dev/null +++ b/src/main/kotlin/app/revanced/patcher/patch/RawResourcePatch.kt @@ -0,0 +1,43 @@ +package app.revanced.patcher.patch + +import app.revanced.patcher.PatchClass +import app.revanced.patcher.Patcher +import app.revanced.patcher.data.ResourceContext +import java.io.Closeable + +/** + * A [Patch] that accesses a [ResourceContext]. + * + * If an implementation of [Patch] also implements [Closeable] + * it will be closed in reverse execution order of patches executed by [Patcher]. + * + * This type of patch that does not have access to decoded resources. + * Instead, you can read and write arbitrary files in an APK file. + * + * If you want to access decoded resources, use [ResourcePatch] instead. + */ +abstract class RawResourcePatch : Patch { + /** + * Create a new [RawResourcePatch]. + */ + constructor() + + /** + * Create a new [RawResourcePatch]. + * + * @param name The name of the patch. + * @param description The description of the patch. + * @param compatiblePackages The packages the patch is compatible with. + * @param dependencies Other patches this patch depends on. + * @param use Weather or not the patch should be used. + * @param requiresIntegrations Weather or not the patch requires integrations. + */ + constructor( + name: String? = null, + description: String? = null, + compatiblePackages: Set? = null, + dependencies: Set? = null, + use: Boolean = true, + requiresIntegrations: Boolean = false, + ) : super(name, description, compatiblePackages, dependencies, use, requiresIntegrations) +} diff --git a/src/main/kotlin/app/revanced/patcher/patch/ResourcePatch.kt b/src/main/kotlin/app/revanced/patcher/patch/ResourcePatch.kt index f0ed13e..1ab3327 100644 --- a/src/main/kotlin/app/revanced/patcher/patch/ResourcePatch.kt +++ b/src/main/kotlin/app/revanced/patcher/patch/ResourcePatch.kt @@ -6,10 +6,15 @@ import app.revanced.patcher.data.ResourceContext import java.io.Closeable /** - * A ReVanced [Patch] that accesses a [ResourceContext]. + * A [Patch] that accesses a [ResourceContext]. * * If an implementation of [Patch] also implements [Closeable] - * it will be closed in reverse execution order of patches executed by ReVanced [Patcher]. + * it will be closed in reverse execution order of patches executed by [Patcher]. + * + * This type of patch has access to decoded resources. + * Additionally, you can read and write arbitrary files in an APK file. + * + * If you do not need access to decoded resources, use [RawResourcePatch] instead. */ abstract class ResourcePatch : Patch { /** diff --git a/src/main/kotlin/app/revanced/patcher/util/Document.kt b/src/main/kotlin/app/revanced/patcher/util/Document.kt new file mode 100644 index 0000000..545ea55 --- /dev/null +++ b/src/main/kotlin/app/revanced/patcher/util/Document.kt @@ -0,0 +1,48 @@ +package app.revanced.patcher.util + +import org.w3c.dom.Document +import java.io.Closeable +import java.io.File +import java.io.InputStream +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +class Document internal constructor( + inputStream: InputStream, +) : Document by DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputStream), Closeable { + private var file: File? = null + + init { + normalize() + } + + internal constructor(file: File) : this(file.inputStream()) { + this.file = file + readerCount.merge(file, 1, Int::plus) + } + + override fun close() { + file?.let { + if (readerCount[it]!! > 1) { + throw IllegalStateException( + "Two or more instances are currently reading $it." + + "To be able to close this instance, no other instances may be reading $it at the same time.", + ) + } else { + readerCount.remove(it) + } + + it.outputStream().use { stream -> + TransformerFactory.newInstance() + .newTransformer() + .transform(DOMSource(this), StreamResult(stream)) + } + } + } + + private companion object { + private val readerCount = mutableMapOf() + } +} diff --git a/src/main/kotlin/app/revanced/patcher/util/DomFileEditor.kt b/src/main/kotlin/app/revanced/patcher/util/DomFileEditor.kt index 4521afc..b7324f6 100644 --- a/src/main/kotlin/app/revanced/patcher/util/DomFileEditor.kt +++ b/src/main/kotlin/app/revanced/patcher/util/DomFileEditor.kt @@ -4,85 +4,22 @@ import org.w3c.dom.Document import java.io.Closeable import java.io.File import java.io.InputStream -import java.io.OutputStream -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.transform.TransformerFactory -import javax.xml.transform.dom.DOMSource -import javax.xml.transform.stream.StreamResult -/** - * Wrapper for a file that can be edited as a dom document. - * - * This constructor does not check for locks to the file when writing. - * Use the secondary constructor. - * - * @param inputStream the input stream to read the xml file from. - * @param outputStream the output stream to write the xml file to. If null, the file will be read only. - * - */ -class DomFileEditor internal constructor( - private val inputStream: InputStream, - private val outputStream: Lazy? = null, -) : Closeable { - // path to the xml file to unlock the resource when closing the editor - private var filePath: String? = null - private var closed: Boolean = false - - /** - * The document of the xml file - */ - val file: Document = - DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputStream) - .also(Document::normalize) - - // lazily open an output stream - // this is required because when constructing a DomFileEditor the output stream is created along with the input stream, which is not allowed - // the workaround is to lazily create the output stream. This way it would be used after the input stream is closed, which happens in the constructor - constructor(file: File) : this(file.inputStream(), lazy { file.outputStream() }) { - // increase the lock - locks.merge(file.path, 1, Integer::sum) - filePath = file.path +@Deprecated("Use Document instead.") +class DomFileEditor : Closeable { + val file: Document + internal constructor( + inputStream: InputStream, + ) { + file = Document(inputStream) + } + + constructor(file: File) { + this.file = Document(file) } - /** - * Closes the editor. Write backs and decreases the lock count. - * - * Will not write back to the file if the file is still locked. - */ override fun close() { - if (closed) return - - inputStream.close() - - // if the output stream is not null, do not close it - outputStream?.let { - // prevent writing to same file, if it is being locked - // isLocked will be false if the editor was created through a stream - val isLocked = - filePath?.let { path -> - val isLocked = locks[path]!! > 1 - // decrease the lock count if the editor was opened for a file - locks.merge(path, -1, Integer::sum) - isLocked - } ?: false - - // if unlocked, write back to the file - if (!isLocked) { - it.value.use { stream -> - val result = StreamResult(stream) - TransformerFactory.newInstance().newTransformer().transform(DOMSource(file), result) - } - - it.value.close() - return - } - } - - closed = true - } - - private companion object { - // map of concurrent open files - val locks = mutableMapOf() + file as app.revanced.patcher.util.Document + file.close() } } diff --git a/src/test/kotlin/app/revanced/patcher/patch/PatchInitializationTest.kt b/src/test/kotlin/app/revanced/patcher/patch/PatchInitializationTest.kt index f780175..6036266 100644 --- a/src/test/kotlin/app/revanced/patcher/patch/PatchInitializationTest.kt +++ b/src/test/kotlin/app/revanced/patcher/patch/PatchInitializationTest.kt @@ -1,7 +1,6 @@ package app.revanced.patcher.patch import app.revanced.patcher.data.ResourceContext -import org.junit.jupiter.api.assertThrows import kotlin.test.Test import app.revanced.patcher.patch.annotation.Patch as PatchAnnotation @@ -9,7 +8,7 @@ object PatchInitializationTest { @Test fun `initialize using constructor`() { val patch = - object : ResourcePatch(name = "Resource patch test") { + object : RawResourcePatch(name = "Resource patch test") { override fun execute(context: ResourceContext) {} } @@ -20,7 +19,7 @@ object PatchInitializationTest { fun `initialize using annotation`() { val patch = @PatchAnnotation("Resource patch test") - object : ResourcePatch() { + object : RawResourcePatch() { override fun execute(context: ResourceContext) {} } diff --git a/src/test/kotlin/app/revanced/patcher/patch/usage/ExampleResourcePatch.kt b/src/test/kotlin/app/revanced/patcher/patch/usage/ExampleResourcePatch.kt index 011c0f0..7cd85c2 100644 --- a/src/test/kotlin/app/revanced/patcher/patch/usage/ExampleResourcePatch.kt +++ b/src/test/kotlin/app/revanced/patcher/patch/usage/ExampleResourcePatch.kt @@ -6,17 +6,9 @@ import org.w3c.dom.Element class ExampleResourcePatch : ResourcePatch() { override fun execute(context: ResourceContext) { - context.xmlEditor["AndroidManifest.xml"].use { editor -> - val element = - editor // regular DomFileEditor - .file - .getElementsByTagName("application") - .item(0) as Element - element - .setAttribute( - "exampleAttribute", - "exampleValue", - ) + context.document["AndroidManifest.xml"].use { document -> + val element = document.getElementsByTagName("application").item(0) as Element + element.setAttribute("exampleAttribute", "exampleValue") } } }