diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml new file mode 100644 index 000000000..36f72b5b2 --- /dev/null +++ b/.github/workflows/clean.yml @@ -0,0 +1,28 @@ +name: Delete old workflow runs +on: + workflow_dispatch: + inputs: + days: + description: 'Number of days.' + required: true + default: 0 + minimum_runs: + description: 'The minimum runs to keep for each workflow.' + required: true + default: 0 + delete_workflow_pattern: + description: 'The name of the workflow. if not set then it will target all workflows.' + required: false + +jobs: + del_runs: + runs-on: ubuntu-22.04 + steps: + - name: Delete workflow runs + uses: Mattraks/delete-workflow-runs@v2 + with: + token: ${{ github.token }} + repository: ${{ github.repository }} + retain_days: ${{ github.event.inputs.days }} + keep_minimum_runs: ${{ github.event.inputs.minimum_runs }} + delete_workflow_pattern: ${{ github.event.inputs.delete_workflow_pattern }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml deleted file mode 100644 index 75b8e67fa..000000000 --- a/.github/workflows/pull_request.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Open a PR to main - -on: - push: - branches: - - dev - workflow_dispatch: - -env: - MESSAGE: Merge branch `${{ github.head_ref || github.ref_name }}` to `main` - -jobs: - pull-request: - name: Open pull request - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Open pull request - uses: repo-sync/pull-request@v2 - with: - destination_branch: 'main' - pr_title: 'chore: ${{ env.MESSAGE }}' - pr_body: 'This pull request will ${{ env.MESSAGE }}.' - pr_draft: true diff --git a/.github/workflows/sync_upstream.yml b/.github/workflows/sync_upstream.yml new file mode 100644 index 000000000..af67ffd08 --- /dev/null +++ b/.github/workflows/sync_upstream.yml @@ -0,0 +1,43 @@ +name: Sync upstream +on: + workflow_call: + workflow_dispatch: + schedule: + - cron: "0 */8 * * *" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + token: ${{ secrets.GH_PAT }} + + - name: sync + id: sync + shell: bash + run: | + git config --global user.name 'E85Addict' + git config --global user.email '77761710+E85Addict@users.noreply.github.com' + if [[ $(git log | grep Author | head -1) == *"semantic"* ]]; then + git reset --hard HEAD~1 + fi + T=$(git tag --sort=creatordate | tail -1) + git remote add upstream https://github.com/revanced/revanced-cli + git tag -d $(git tag -l) + git fetch upstream --tags -f + LatestRemoteTag=$(curl -s https://api.github.com/repos/revanced/revanced-cli/releases/latest | jq -r '.tag_name') + C=$(git rev-list --left-right --count origin/main...remotes/upstream/main | awk '{print$2}') + echo "Ahead $C commits." + if [ "$C" -gt 0 ]; then + echo "Rebasing" + # git push origin --delete $T + git rebase -X ours upstream/main + git push --tags -f + git push origin --delete $LatestRemoteTag + git push -f + else + echo "in sync" + fi diff --git a/.github/workflows/update-documentation.yml b/.github/workflows/update-documentation.yml deleted file mode 100644 index 77097e2fe..000000000 --- a/.github/workflows/update-documentation.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Update documentation - -on: - push: - paths: - - docs/** - -jobs: - trigger: - runs-on: ubuntu-latest - name: Dispatch event to documentation repository - if: github.ref == 'refs/heads/main' - steps: - - uses: peter-evans/repository-dispatch@v2 - with: - token: ${{ secrets.DOCUMENTATION_REPO_ACCESS_TOKEN }} - repository: revanced/revanced-documentation - event-type: update-documentation - client-payload: '{"repo": "${{ github.event.repository.name }}", "ref": "${{ github.ref }}"}' diff --git a/.releaserc b/.releaserc index 85c3f7020..4d235afe9 100644 --- a/.releaserc +++ b/.releaserc @@ -31,18 +31,11 @@ { "assets": [ { - "path": "build/libs/*all.jar" + "path": "revanced-cli/build/libs/*all.jar" } ], successComment: false } - ], - [ - "@saithodev/semantic-release-backmerge", - { - backmergeBranches: [{"from": "main", "to": "dev"}], - clearWorkspace: true - } ] ] } diff --git a/build.gradle.kts b/build.gradle.kts index fc12ab73f..2ab434666 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,70 +1,7 @@ plugins { - kotlin("jvm") version "1.9.10" - alias(libs.plugins.shadow) + kotlin("jvm") version "1.9.10" apply false } -group = "app.revanced" - -repositories { - mavenCentral() - mavenLocal() - google() - maven { url = uri("https://jitpack.io") } -} - -dependencies { - implementation(libs.revanced.patcher) - implementation(libs.revanced.library) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.picocli) - - testImplementation(libs.kotlin.test) -} - -kotlin { jvmToolchain(11) } - -tasks { - test { - useJUnitPlatform() - testLogging { - events("PASSED", "SKIPPED", "FAILED") - } - } - - processResources { - expand("projectVersion" to project.version) - } - - shadowJar { - manifest { - attributes("Main-Class" to "app.revanced.cli.command.MainCommandKt") - } - minimize { - exclude(dependency("org.jetbrains.kotlin:.*")) - exclude(dependency("org.bouncycastle:.*")) - exclude(dependency("app.revanced:.*")) - } - } - - build { - dependsOn(shadowJar) - } - - /* - Dummy task to hack gradle-semantic-release-plugin to release this project. - - Explanation: - SemVer is a standard for versioning libraries. - For that reason the semantic-release plugin uses the "publish" task to publish libraries. - However, this subproject is not a library, and the "publish" task is not available for this subproject. - Because semantic-release is not designed to handle this case, we need to hack it. - - RE: https://github.com/KengoTODA/gradle-semantic-release-plugin/issues/435 - */ - - register("publish") { - group = "publishing" - description = "Dummy task to hack gradle-semantic-release-plugin to release ReVanced CLI" - dependsOn(build) - } -} +allprojects { + group = "app.revanced" +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f70eea167..8f8be2dcb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,25 @@ [versions] shadow = "8.1.1" +apksig = "8.1.2" +bcpkix-jdk18on = "1.76" +jackson-module-kotlin = "2.15.3" +jadb = "1.2.1" +kotlin-reflect = "1.9.10" kotlin-test = "1.9.20" kotlinx-coroutines-core = "1.7.3" -picocli = "4.7.3" +picocli = "4.7.5" revanced-patcher = "19.0.0" -revanced-library = "1.3.0" [libraries] +apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" } +bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bcpkix-jdk18on" } +jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson-module-kotlin" } +jadb = { module = "app.revanced:jadb", version.ref = "jadb" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin-reflect" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin-test" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-core" } picocli = { module = "info.picocli:picocli", version.ref = "picocli" } revanced-patcher = { module = "app.revanced:revanced-patcher", version.ref = "revanced-patcher" } -revanced-library = { module = "app.revanced:revanced-library", version.ref = "revanced-library" } [plugins] shadow = { id = "com.github.johnrengelman.shadow", version.ref = "shadow" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c4c..7f93135c4 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a59d94b7a..f1b60f90f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,5 +2,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip distributionSha256Sum=3e1af3ae886920c3ac87f7a91f816c0c7c436f276a6eefdb3da152100fef72ae +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dist \ No newline at end of file diff --git a/gradlew b/gradlew index fcb6fca14..1aa94a426 100755 --- a/gradlew +++ b/gradlew @@ -83,7 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -144,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -201,11 +202,11 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ diff --git a/revanced-cli/build.gradle.kts b/revanced-cli/build.gradle.kts new file mode 100644 index 000000000..0267e8694 --- /dev/null +++ b/revanced-cli/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + kotlin("jvm") version "1.9.10" + alias(libs.plugins.shadow) +} + +dependencies { + implementation(project(":revanced-lib")) + implementation(libs.revanced.patcher) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.picocli) + + testImplementation(libs.kotlin.test) +} + +kotlin { jvmToolchain(11) } + +tasks { + test { + useJUnitPlatform() + testLogging { + events("PASSED", "SKIPPED", "FAILED") + } + } + + processResources { + expand("projectVersion" to project.version) + } + + shadowJar { + manifest { + attributes("Main-Class" to "app.revanced.cli.command.MainCommandKt") + } + minimize { + exclude(dependency("org.jetbrains.kotlin:.*")) + exclude(dependency("org.bouncycastle:.*")) + exclude(dependency("app.revanced:.*")) + } + } + + build { + dependsOn(shadowJar) + } + + // Dummy task to fix the Gradle semantic-release plugin. + // Remove this if you forked it to support building only. + // Tracking issue: https://github.com/KengoTODA/gradle-semantic-release-plugin/issues/435 + register("publish") { + group = "publish" + description = "Dummy task" + dependsOn(build) + } +} diff --git a/revanced-cli/settings.gradle.kts b/revanced-cli/settings.gradle.kts new file mode 100644 index 000000000..47352d093 --- /dev/null +++ b/revanced-cli/settings.gradle.kts @@ -0,0 +1,7 @@ +rootProject.name = "revanced-cli" + +buildCache { + local { + isEnabled = !System.getenv().containsKey("CI") + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/cli/command/ListPatchesCommand.kt b/revanced-cli/src/main/kotlin/app/revanced/cli/command/ListPatchesCommand.kt similarity index 100% rename from src/main/kotlin/app/revanced/cli/command/ListPatchesCommand.kt rename to revanced-cli/src/main/kotlin/app/revanced/cli/command/ListPatchesCommand.kt diff --git a/src/main/kotlin/app/revanced/cli/command/MainCommand.kt b/revanced-cli/src/main/kotlin/app/revanced/cli/command/MainCommand.kt similarity index 100% rename from src/main/kotlin/app/revanced/cli/command/MainCommand.kt rename to revanced-cli/src/main/kotlin/app/revanced/cli/command/MainCommand.kt diff --git a/src/main/kotlin/app/revanced/cli/command/OptionsCommand.kt b/revanced-cli/src/main/kotlin/app/revanced/cli/command/OptionsCommand.kt similarity index 100% rename from src/main/kotlin/app/revanced/cli/command/OptionsCommand.kt rename to revanced-cli/src/main/kotlin/app/revanced/cli/command/OptionsCommand.kt diff --git a/src/main/kotlin/app/revanced/cli/command/PatchCommand.kt b/revanced-cli/src/main/kotlin/app/revanced/cli/command/PatchCommand.kt similarity index 97% rename from src/main/kotlin/app/revanced/cli/command/PatchCommand.kt rename to revanced-cli/src/main/kotlin/app/revanced/cli/command/PatchCommand.kt index 410c94aa5..6a2cf7681 100644 --- a/src/main/kotlin/app/revanced/cli/command/PatchCommand.kt +++ b/revanced-cli/src/main/kotlin/app/revanced/cli/command/PatchCommand.kt @@ -216,6 +216,17 @@ internal object PatchCommand : Runnable { this.aaptBinaryPath = aaptBinaryPath } + @CommandLine.Option( + names = ["--unsigned"], description = ["Disable signing of the final apk."] + ) + private var unsigned: Boolean = false + + @CommandLine.Option( + names = ["--rip-lib"], description = ["Rip native libs from APK. (x86_64 etc.)"] + ) + private var ripLibs = arrayOf() + + override fun run() { // region Setup @@ -309,7 +320,7 @@ internal object PatchCommand : Runnable { ApkUtils.copyAligned(apk, this, patcherResult) } - if (!mount) { + if (!mount && !unsigned) { ApkUtils.sign( alignedFile, outputFilePath, diff --git a/src/main/kotlin/app/revanced/cli/command/utility/InstallCommand.kt b/revanced-cli/src/main/kotlin/app/revanced/cli/command/utility/InstallCommand.kt similarity index 100% rename from src/main/kotlin/app/revanced/cli/command/utility/InstallCommand.kt rename to revanced-cli/src/main/kotlin/app/revanced/cli/command/utility/InstallCommand.kt diff --git a/src/main/kotlin/app/revanced/cli/command/utility/UninstallCommand.kt b/revanced-cli/src/main/kotlin/app/revanced/cli/command/utility/UninstallCommand.kt similarity index 100% rename from src/main/kotlin/app/revanced/cli/command/utility/UninstallCommand.kt rename to revanced-cli/src/main/kotlin/app/revanced/cli/command/utility/UninstallCommand.kt diff --git a/src/main/kotlin/app/revanced/cli/command/utility/UtilityCommand.kt b/revanced-cli/src/main/kotlin/app/revanced/cli/command/utility/UtilityCommand.kt similarity index 100% rename from src/main/kotlin/app/revanced/cli/command/utility/UtilityCommand.kt rename to revanced-cli/src/main/kotlin/app/revanced/cli/command/utility/UtilityCommand.kt diff --git a/src/main/resources/app/revanced/cli/version.properties b/revanced-cli/src/main/resources/app/revanced/cli/version.properties similarity index 100% rename from src/main/resources/app/revanced/cli/version.properties rename to revanced-cli/src/main/resources/app/revanced/cli/version.properties diff --git a/revanced-lib/build.gradle.kts b/revanced-lib/build.gradle.kts new file mode 100644 index 000000000..cae6ac26c --- /dev/null +++ b/revanced-lib/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + kotlin("jvm") version "1.9.10" +} + +dependencies { + implementation(libs.revanced.patcher) + implementation(libs.kotlin.reflect) + implementation(libs.jadb) // Updated fork + implementation(libs.apksig) + implementation(libs.bcpkix.jdk18on) + implementation(libs.jackson.module.kotlin) +} + +tasks { +} + +kotlin { jvmToolchain(11) } + +java { + withSourcesJar() +} diff --git a/revanced-lib/settings.gradle.kts b/revanced-lib/settings.gradle.kts new file mode 100644 index 000000000..f1bcd7292 --- /dev/null +++ b/revanced-lib/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "revanced-lib" \ No newline at end of file diff --git a/revanced-lib/src/main/kotlin/app/revanced/library/ApkSigner.kt b/revanced-lib/src/main/kotlin/app/revanced/library/ApkSigner.kt new file mode 100644 index 000000000..d01bc73ce --- /dev/null +++ b/revanced-lib/src/main/kotlin/app/revanced/library/ApkSigner.kt @@ -0,0 +1,266 @@ +package app.revanced.library + +import com.android.apksig.ApkSigner +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.bouncycastle.cert.X509v3CertificateBuilder +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.math.BigInteger +import java.security.* +import java.security.cert.X509Certificate +import java.util.* +import java.util.logging.Logger +import kotlin.time.Duration.Companion.days + +/** + * Utility class for writing or reading keystore files and entries as well as signing APK files. + */ +@Suppress("MemberVisibilityCanBePrivate", "unused") +object ApkSigner { + private val logger = Logger.getLogger(app.revanced.library.ApkSigner::class.java.name) + + init { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) + Security.addProvider(BouncyCastleProvider()) + } + + /** + * Create a new [PrivateKeyCertificatePair]. + * + * @param commonName The common name of the certificate. + * @param validUntil The date until the certificate is valid. + * @return The created [PrivateKeyCertificatePair]. + */ + fun newPrivateKeyCertificatePair( + commonName: String = "ReVanced", + validUntil: Date = Date(System.currentTimeMillis() + 356.days.inWholeMilliseconds * 24) + ): PrivateKeyCertificatePair { + logger.fine("Creating certificate for $commonName") + + // Generate a new key pair. + val keyPair = KeyPairGenerator.getInstance("RSA").apply { + initialize(4096) + }.generateKeyPair() + + var serialNumber: BigInteger + do serialNumber = BigInteger.valueOf(SecureRandom().nextLong()) + while (serialNumber < BigInteger.ZERO) + + val name = X500Name("CN=$commonName") + + // Create a new certificate. + val certificate = JcaX509CertificateConverter().getCertificate( + X509v3CertificateBuilder( + name, + serialNumber, + Date(System.currentTimeMillis()), + validUntil, + Locale.ENGLISH, + name, + SubjectPublicKeyInfo.getInstance(keyPair.public.encoded) + ).build(JcaContentSignerBuilder("SHA256withRSA").build(keyPair.private)) + ) + + return PrivateKeyCertificatePair(keyPair.private, certificate) + } + + + /** + * Read a [PrivateKeyCertificatePair] from a keystore entry. + * + * @param keyStore The keystore to read the entry from. + * @param keyStoreEntryAlias The alias of the key store entry to read. + * @param keyStoreEntryPassword The password for recovering the signing key. + * @return The read [PrivateKeyCertificatePair]. + * @throws IllegalArgumentException If the keystore does not contain the given alias or the password is invalid. + */ + fun readKeyCertificatePair( + keyStore: KeyStore, + keyStoreEntryAlias: String, + keyStoreEntryPassword: String, + ): PrivateKeyCertificatePair { + logger.fine("Reading key and certificate pair from keystore entry $keyStoreEntryAlias") + + if (!keyStore.containsAlias(keyStoreEntryAlias)) + throw IllegalArgumentException("Keystore does not contain alias $keyStoreEntryAlias") + + // Read the private key and certificate from the keystore. + + val privateKey = try { + keyStore.getKey(keyStoreEntryAlias, keyStoreEntryPassword.toCharArray()) as PrivateKey + } catch (exception: UnrecoverableKeyException) { + throw IllegalArgumentException("Invalid password for keystore entry $keyStoreEntryAlias") + } + + val certificate = keyStore.getCertificate(keyStoreEntryAlias) as X509Certificate + + return PrivateKeyCertificatePair(privateKey, certificate) + } + + /** + * Create a new keystore with a new keypair. + * + * @param entries The entries to add to the keystore. + * @return The created keystore. + * @see KeyStoreEntry + */ + fun newKeyStore( + entries: List + ): KeyStore { + logger.fine("Creating keystore") + + return KeyStore.getInstance("BKS", BouncyCastleProvider.PROVIDER_NAME).apply { + load(null) + + entries.forEach { entry -> + // Add all entries to the keystore. + setKeyEntry( + entry.alias, + entry.privateKeyCertificatePair.privateKey, + entry.password.toCharArray(), + arrayOf(entry.privateKeyCertificatePair.certificate) + ) + } + } + } + + /** + * Create a new keystore with a new keypair and saves it to the given [keyStoreOutputStream]. + * + * @param keyStoreOutputStream The stream to write the keystore to. + * @param keyStorePassword The password for the keystore. + * @param entries The entries to add to the keystore. + */ + fun newKeyStore( + keyStoreOutputStream: OutputStream, + keyStorePassword: String, + entries: List + ) = newKeyStore(entries).store( + keyStoreOutputStream, + keyStorePassword.toCharArray() + ) // Save the keystore. + + /** + * Read a keystore from the given [keyStoreInputStream]. + * + * @param keyStoreInputStream The stream to read the keystore from. + * @param keyStorePassword The password for the keystore. + * @return The keystore. + * @throws IllegalArgumentException If the keystore password is invalid. + */ + fun readKeyStore( + keyStoreInputStream: InputStream, + keyStorePassword: String? + ): KeyStore { + logger.fine("Reading keystore") + + return KeyStore.getInstance("BKS", BouncyCastleProvider.PROVIDER_NAME).apply { + try { + load(keyStoreInputStream, keyStorePassword?.toCharArray()) + } catch (exception: IOException) { + if (exception.cause is UnrecoverableKeyException) + throw IllegalArgumentException("Invalid keystore password") + else + throw exception + } + } + } + + /** + * Create a new [ApkSigner.Builder]. + * + * @param privateKeyCertificatePair The private key and certificate pair to use for signing. + * @param signer The name of the signer. + * @param createdBy The value for the `Created-By` attribute in the APK's manifest. + * @return The created [ApkSigner.Builder] instance. + */ + fun newApkSignerBuilder( + privateKeyCertificatePair: PrivateKeyCertificatePair, + signer: String, + createdBy: String + ): ApkSigner.Builder { + logger.fine( + "Creating new ApkSigner " + + "with $signer as signer and " + + "$createdBy as Created-By attribute in the APK's manifest" + ) + + // Create the signer config. + val signerConfig = ApkSigner.SignerConfig.Builder( + signer, + privateKeyCertificatePair.privateKey, + listOf(privateKeyCertificatePair.certificate) + ).build() + + // Create the signer. + return ApkSigner.Builder(listOf(signerConfig)).apply { + setCreatedBy(createdBy) + } + } + + /** + * Create a new [ApkSigner.Builder]. + * + * @param keyStore The keystore to use for signing. + * @param keyStoreEntryAlias The alias of the key store entry to use for signing. + * @param keyStoreEntryPassword The password for recovering the signing key. + * @param signer The name of the signer. + * @param createdBy The value for the `Created-By` attribute in the APK's manifest. + * @return The created [ApkSigner.Builder] instance. + * @see KeyStoreEntry + * @see PrivateKeyCertificatePair + * @see ApkSigner.Builder.setCreatedBy + * @see ApkSigner.Builder.signApk + */ + fun newApkSignerBuilder( + keyStore: KeyStore, + keyStoreEntryAlias: String, + keyStoreEntryPassword: String, + signer: String, + createdBy: String, + ) = newApkSignerBuilder( + readKeyCertificatePair(keyStore, keyStoreEntryAlias, keyStoreEntryPassword), + signer, + createdBy + ) + + fun ApkSigner.Builder.signApk(input: File, output: File) { + logger.info("Signing ${input.name}") + + setInputApk(input) + setOutputApk(output) + + build().sign() + } + + /** + * An entry in a keystore. + * + * @param alias The alias of the entry. + * @param password The password for recovering the signing key. + * @param privateKeyCertificatePair The private key and certificate pair. + * @see PrivateKeyCertificatePair + */ + class KeyStoreEntry( + val alias: String, + val password: String, + val privateKeyCertificatePair: PrivateKeyCertificatePair = newPrivateKeyCertificatePair() + ) + + /** + * A private key and certificate pair. + * + * @param privateKey The private key. + * @param certificate The certificate. + */ + class PrivateKeyCertificatePair( + val privateKey: PrivateKey, + val certificate: X509Certificate, + ) +} \ No newline at end of file diff --git a/revanced-lib/src/main/kotlin/app/revanced/library/ApkUtils.kt b/revanced-lib/src/main/kotlin/app/revanced/library/ApkUtils.kt new file mode 100644 index 000000000..dbe18aaf5 --- /dev/null +++ b/revanced-lib/src/main/kotlin/app/revanced/library/ApkUtils.kt @@ -0,0 +1,115 @@ +package app.revanced.library + +import app.revanced.library.ApkSigner.signApk +import app.revanced.library.zip.ZipFile +import app.revanced.library.zip.structures.ZipEntry +import app.revanced.patcher.PatcherResult +import java.io.File +import java.util.logging.Logger +import kotlin.io.path.deleteIfExists + +/** + * Utility functions for working with apks. + */ +@Suppress("MemberVisibilityCanBePrivate", "unused") +object ApkUtils { + private val logger = Logger.getLogger(ApkUtils::class.java.name) + + /** + * Creates a new apk from [apkFile] and [patchedEntriesSource] and writes it to [outputFile]. + * + * @param apkFile The apk to copy entries from. + * @param outputFile The apk to write the new entries to. + * @param patchedEntriesSource The result of the patcher to add the patched dex files and resources. + * @param exclude An array of libraries to remove. + */ + fun copyAligned( + apkFile: File, + outputFile: File, + patchedEntriesSource: PatcherResult, + exclude: Array + ) { + logger.info("Aligning ${apkFile.name}") + + outputFile.toPath().deleteIfExists() + + ZipFile(outputFile).use { file -> + patchedEntriesSource.dexFiles.forEach { + file.addEntryCompressData( + ZipEntry(it.name), + it.stream.readBytes() + ) + } + + patchedEntriesSource.resourceFile?.let { + file.copyEntriesFromFileAligned( + ZipFile(it), + ZipFile.apkZipEntryAlignment + ) + } + + // TODO: Do not compress result.doNotCompress + + // TODO: Fix copying resources that are not needed anymore. + val inputZipFile = ZipFile(apkFile) + inputZipFile.entries.removeIf { entry -> exclude.any { entry.fileName.startsWith("lib/$it") } } + file.copyEntriesFromFileAligned( + inputZipFile, + ZipFile.apkZipEntryAlignment + ) + } + } + + /** + * Signs the [apk] file and writes it to [output]. + * + * @param apk The apk to sign. + * @param output The apk to write the signed apk to. + * @param signingOptions The options to use for signing. + */ + fun sign( + apk: File, + output: File, + signingOptions: SigningOptions, + ) { + // Get the keystore from the file or create a new one. + val keyStore = if (signingOptions.keyStore.exists()) { + ApkSigner.readKeyStore(signingOptions.keyStore.inputStream(), signingOptions.keyStorePassword) + } else { + val entry = ApkSigner.KeyStoreEntry(signingOptions.alias, signingOptions.password) + + // Create a new keystore with a new keypair and saves it. + ApkSigner.newKeyStore(listOf(entry)).also { keyStore -> + keyStore.store( + signingOptions.keyStore.outputStream(), + signingOptions.keyStorePassword?.toCharArray() + ) + } + } + + ApkSigner.newApkSignerBuilder( + keyStore, + signingOptions.alias, + signingOptions.password, + signingOptions.signer, + signingOptions.signer + ).signApk(apk, output) + } + + /** + * Options for signing an apk. + * + * @param keyStore The keystore to use for signing. + * @param keyStorePassword The password for the keystore. + * @param alias The alias of the key store entry to use for signing. + * @param password The password for recovering the signing key. + * @param signer The name of the signer. + */ + class SigningOptions( + val keyStore: File, + val keyStorePassword: String?, + val alias: String = "ReVanced Key", + val password: String = "", + val signer: String = "ReVanced", + ) +} \ No newline at end of file diff --git a/revanced-lib/src/main/kotlin/app/revanced/library/Options.kt b/revanced-lib/src/main/kotlin/app/revanced/library/Options.kt new file mode 100644 index 000000000..d6fe3517f --- /dev/null +++ b/revanced-lib/src/main/kotlin/app/revanced/library/Options.kt @@ -0,0 +1,118 @@ +@file:Suppress("MemberVisibilityCanBePrivate") + +package app.revanced.library + + +import app.revanced.library.Options.Patch.Option +import app.revanced.patcher.PatchClass +import app.revanced.patcher.PatchSet +import app.revanced.patcher.patch.options.PatchOptionException +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.io.File +import java.util.logging.Logger + +private typealias PatchList = List + +object Options { + private val logger = Logger.getLogger(Options::class.java.name) + + private var mapper = jacksonObjectMapper() + + /** + * Serializes the options for the patches in the list. + * + * @param patches The list of patches to serialize. + * @param prettyPrint Whether to pretty print the JSON. + * @return The JSON string containing the options. + */ + fun serialize(patches: PatchSet, prettyPrint: Boolean = false): String = patches + .filter { it.options.any() } + .map { patch -> + Patch( + patch.name!!, + patch.options.values.map { option -> + val optionValue = try { + option.value + } catch (e: PatchOptionException) { + logger.warning("Using default option value for the ${patch.name} patch: ${e.message}") + option.default + } + + Option(option.key, optionValue) + } + ) + } + // See https://github.com/revanced/revanced-patches/pull/2434/commits/60e550550b7641705e81aa72acfc4faaebb225e7. + .distinctBy { it.patchName } + .let { + if (prettyPrint) + mapper.writerWithDefaultPrettyPrinter().writeValueAsString(it) + else + mapper.writeValueAsString(it) + } + + /** + * Deserializes the options for the patches in the list. + * + * @param json The JSON string containing the options. + * @return The list of [Patch]s. + * @see Patch + * @see PatchList + */ + fun deserialize(json: String): Array = mapper.readValue(json, Array::class.java) + + /** + * Sets the options for the patches in the list. + * + * @param json The JSON string containing the options. + */ + fun PatchSet.setOptions(json: String) { + filter { it.options.any() }.let { patches -> + if (patches.isEmpty()) return + + val jsonPatches = deserialize(json).associate { + it.patchName to it.options.associate { option -> option.key to option.value } + } + + patches.forEach { patch -> + jsonPatches[patch.name]?.let { jsonPatchOptions -> + jsonPatchOptions.forEach { (option, value) -> + try { + patch.options[option] = value + } catch (e: PatchOptionException) { + logger.warning("Could not set option value for the ${patch.name} patch: ${e.message}") + } + } + } + } + } + } + + /** + * Sets the options for the patches in the list. + * + * @param file The file containing the JSON string containing the options. + * @see setOptions + */ + fun PatchSet.setOptions(file: File) = setOptions(file.readText()) + + /** + * Data class for a patch and its [Option]s. + * + * @property patchName The name of the patch. + * @property options The [Option]s for the patch. + */ + class Patch internal constructor( + val patchName: String, + val options: List